Compare commits
589 Commits
fix/issue-
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0514622f39 | ||
|
|
24c85c29e4 | ||
|
|
778402fec7 | ||
|
|
6db5c2d1c4 | ||
|
|
2f981ef69d | ||
|
|
7074c9e4ad | ||
|
|
8eced9c9da | ||
|
|
28de7da9a6 | ||
|
|
8189e14a4b | ||
|
|
bdc37b1156 | ||
|
|
314f686963 | ||
|
|
a23fa4c668 | ||
|
|
05ab8b13a0 | ||
|
|
1052295a6e | ||
|
|
c3d1bea623 | ||
|
|
97585a9cd4 | ||
|
|
c32607e133 | ||
|
|
d7eca25eb7 | ||
|
|
fdb9ae31ae | ||
|
|
14deae962a | ||
|
|
924c76f99f | ||
|
|
99a4230bb9 | ||
|
|
38818998e5 | ||
|
|
9b4da70f52 | ||
|
|
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 | ||
|
|
e975642a4c | ||
|
|
72f422afe2 | ||
|
|
6074480482 | ||
|
|
5512790d5a | ||
|
|
a158048f45 | ||
|
|
ac999066dd | ||
|
|
8b25a5b940 | ||
|
|
265b4f1484 | ||
|
|
bfc3a17676 | ||
|
|
eb54a98ea2 | ||
|
|
3fcdfa85f1 | ||
|
|
cd1c0b210e | ||
|
|
a239c16c31 | ||
|
|
8a8205ad8d | ||
|
|
0430383e1c | ||
|
|
e2d74ff880 | ||
|
|
586eea009b |
@@ -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:
|
||||||
@@ -32,23 +33,125 @@ jobs:
|
|||||||
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
|
||||||
|
env:
|
||||||
|
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
|
||||||
|
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
|
||||||
@@ -60,11 +163,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 ───────────────────────────────────────────────
|
||||||
@@ -74,6 +177,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
|
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
|
||||||
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -92,5 +197,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
|
||||||
217
.gitea/workflows/release.yml
Normal file
217
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
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 pins to the bridge gateway IP (the host), not 127.0.0.1
|
||||||
|
# — see nightly.yml for the full network topology explanation.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
HOST="archiv.raddatz.cloud"
|
||||||
|
URL="https://$HOST"
|
||||||
|
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
|
||||||
|
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip 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
|
||||||
37
CLAUDE.md
37
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`.
|
||||||
|
|
||||||
### 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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,71 @@
|
|||||||
|
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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,7 +100,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. */
|
||||||
@@ -485,7 +485,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,39 +645,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 +692,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());
|
||||||
@@ -1013,6 +1050,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) {}
|
||||||
@@ -27,7 +27,9 @@ public class CommentController {
|
|||||||
// ─── Block (transcription) comments ────────────────────────────────────────
|
// ─── Block (transcription) comments ────────────────────────────────────────
|
||||||
|
|
||||||
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||||
public List<DocumentComment> getBlockComments(@PathVariable UUID blockId) {
|
public List<DocumentComment> getBlockComments(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID blockId) {
|
||||||
return commentService.getCommentsForBlock(blockId);
|
return commentService.getCommentsForBlock(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ public class CommentController {
|
|||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment replyToBlockComment(
|
public DocumentComment replyToBlockComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID blockId,
|
||||||
@PathVariable UUID commentId,
|
@PathVariable UUID commentId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
|||||||
@@ -39,6 +39,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 +55,8 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -63,6 +64,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,6 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
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.*;
|
||||||
@@ -52,9 +53,9 @@ 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 ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
|
||||||
|
|
||||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "Kein Import gestartet.", 0, null);
|
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||||
|
|
||||||
public ImportStatus getStatus() {
|
public ImportStatus getStatus() {
|
||||||
return currentStatus;
|
return currentStatus;
|
||||||
@@ -99,7 +100,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 +117,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, 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));
|
int processed = processRows(readSpreadsheet(spreadsheet));
|
||||||
currentStatus = new ImportStatus(State.DONE,
|
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
||||||
processed, currentStatus.startedAt());
|
processed, 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, 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, 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 +168,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));
|
||||||
@@ -378,7 +390,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,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ public class AppUser {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public static String computeColor(UUID id) {
|
public static String computeColor(UUID id) {
|
||||||
return PALETTE[Math.abs(id.hashCode()) % PALETTE.length];
|
// Math.floorMod avoids the Integer.MIN_VALUE overflow trap in Math.abs(hashCode())
|
||||||
|
return PALETTE[Math.floorMod(id.hashCode(), PALETTE.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
|
|||||||
@@ -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
|
||||||
.name("Administrators")
|
// previous boot seeded the group but failed before creating
|
||||||
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
// the admin user, or the operator deleted just the user row
|
||||||
.build();
|
// to retry the seed with a new email). Blind-INSERTing would
|
||||||
groupRepository.save(adminGroup);
|
// violate user_groups_name_key and abort the context. See #518.
|
||||||
|
UserGroup adminGroup = groupRepository.findByName("Administrators")
|
||||||
|
.orElseGet(() -> groupRepository.save(UserGroup.builder()
|
||||||
|
.name("Administrators")
|
||||||
|
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
||||||
|
.build()));
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -271,9 +274,10 @@ public class UserService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public UserGroup createGroup(GroupDTO dto) {
|
public UserGroup createGroup(GroupDTO dto) {
|
||||||
UserGroup group = new UserGroup();
|
UserGroup group = UserGroup.builder()
|
||||||
group.setName(dto.getName());
|
.name(dto.getName())
|
||||||
group.setPermissions(dto.getPermissions());
|
.permissions(dto.getPermissions() != null ? dto.getPermissions() : new HashSet<>())
|
||||||
|
.build();
|
||||||
return groupRepository.save(group);
|
return groupRepository.save(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,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,7 @@
|
|||||||
|
-- Remove duplicate (group_id, permission) rows that accumulated without a UNIQUE constraint.
|
||||||
|
-- Keeps the row with the smallest ctid (earliest physical insertion order).
|
||||||
|
DELETE FROM group_permissions a
|
||||||
|
USING group_permissions b
|
||||||
|
WHERE a.ctid < b.ctid
|
||||||
|
AND a.group_id = b.group_id
|
||||||
|
AND a.permission = b.permission;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Add NOT NULL and PRIMARY KEY to group_permissions.
|
||||||
|
-- Requires V63 to have run first (no duplicates can remain).
|
||||||
|
--
|
||||||
|
-- After this migration, future seed migrations can use:
|
||||||
|
-- INSERT INTO group_permissions ... ON CONFLICT DO NOTHING
|
||||||
|
-- instead of the INSERT ... WHERE NOT EXISTS pattern used before V64.
|
||||||
|
ALTER TABLE group_permissions
|
||||||
|
ALTER COLUMN permission SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE group_permissions
|
||||||
|
ADD CONSTRAINT pk_group_permissions PRIMARY KEY (group_id, permission);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Promote the de-facto unique constraint on transcription_block_mentioned_persons to a named PK.
|
||||||
|
-- uq_tbmp_block_person (added in V57) is backed by a B-tree index identical to a PK;
|
||||||
|
-- this rename makes the naming convention explicit (pk_* vs uq_*).
|
||||||
|
ALTER TABLE transcription_block_mentioned_persons
|
||||||
|
DROP CONSTRAINT uq_tbmp_block_person;
|
||||||
|
|
||||||
|
ALTER TABLE transcription_block_mentioned_persons
|
||||||
|
ADD CONSTRAINT pk_tbmp PRIMARY KEY (block_id, person_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,86 @@ 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 ──────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v64_pk_group_permissions_exists() {
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
|
||||||
|
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
|
||||||
|
WHERE t.relname = 'group_permissions'
|
||||||
|
AND c.conname = 'pk_group_permissions'
|
||||||
|
AND c.contype = 'p'
|
||||||
|
""",
|
||||||
|
Integer.class);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v64_permission_column_isNotNullable() {
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'group_permissions'
|
||||||
|
AND column_name = 'permission'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
""",
|
||||||
|
Integer.class);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
void v64_rejectsDuplicateGroupPermission() {
|
||||||
|
UUID groupId = createUserGroup("DuplicateTestGroup-" + UUID.randomUUID());
|
||||||
|
try {
|
||||||
|
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId);
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId)
|
||||||
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
} finally {
|
||||||
|
jdbc.update("DELETE FROM group_permissions WHERE group_id = ?", groupId);
|
||||||
|
jdbc.update("DELETE FROM user_groups WHERE id = ?", groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── V65: tbmp UNIQUE promoted to PRIMARY KEY ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v65_pk_tbmp_exists() {
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
|
||||||
|
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
|
||||||
|
WHERE t.relname = 'transcription_block_mentioned_persons'
|
||||||
|
AND c.conname = 'pk_tbmp'
|
||||||
|
AND c.contype = 'p'
|
||||||
|
""",
|
||||||
|
Integer.class);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private UUID createPerson(String firstName, String lastName) {
|
private UUID createPerson(String firstName, String lastName) {
|
||||||
@@ -482,4 +562,10 @@ class MigrationIntegrationTest {
|
|||||||
""", id, recipientId, docId, commentId);
|
""", id, recipientId, docId, commentId);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UUID createUserGroup(String name) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("INSERT INTO user_groups (id, name) VALUES (?, ?)", id, name);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,136 @@
|
|||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -44,6 +45,14 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
// ─── Block comment endpoints ─────────────────────────────────────────────
|
// ─── Block comment endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getBlockComments_returns400_when_documentId_is_not_a_UUID() throws Exception {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
mockMvc.perform(get("/api/documents/NOT-A-UUID/transcription-blocks/" + blockId + "/comments"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void getBlockComments_returns200() throws Exception {
|
void getBlockComments_returns200() throws Exception {
|
||||||
@@ -62,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()));
|
||||||
@@ -71,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());
|
||||||
}
|
}
|
||||||
@@ -80,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());
|
||||||
}
|
}
|
||||||
@@ -93,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());
|
||||||
}
|
}
|
||||||
@@ -108,18 +117,27 @@ 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Block reply endpoints ───────────────────────────────────────────────
|
// ─── Block reply endpoints ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
||||||
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
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());
|
||||||
}
|
}
|
||||||
@@ -134,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());
|
||||||
}
|
}
|
||||||
@@ -149,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());
|
||||||
}
|
}
|
||||||
@@ -158,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());
|
||||||
}
|
}
|
||||||
@@ -170,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());
|
||||||
}
|
}
|
||||||
@@ -182,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());
|
||||||
}
|
}
|
||||||
@@ -191,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, LocalDateTime.now());
|
||||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.runImportAsync())
|
assertThatThrownBy(() -> service.runImportAsync())
|
||||||
@@ -472,6 +525,25 @@ class MassImportServiceTest {
|
|||||||
assertThat(result).isEqualTo("hello");
|
assertThat(result).isEqualTo("hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 +577,48 @@ 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, 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, 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,15 @@ class AppUserTest {
|
|||||||
.count();
|
.count();
|
||||||
assertThat(distinct).isGreaterThan(1);
|
assertThat(distinct).isGreaterThan(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void computeColor_returnsValidPaletteColorForIntegerMinValueHash() {
|
||||||
|
// UUID "80000000-0000-0000-0000-000000000000" has hashCode() == Integer.MIN_VALUE.
|
||||||
|
// Math.abs(Integer.MIN_VALUE) overflows back to Integer.MIN_VALUE (negative), making
|
||||||
|
// Math.abs(hashCode()) % n unsafe for palette sizes that don't evenly divide MIN_VALUE.
|
||||||
|
// Math.floorMod eliminates this edge case entirely.
|
||||||
|
UUID minHashId = UUID.fromString("80000000-0000-0000-0000-000000000000");
|
||||||
|
assertThat(minHashId.hashCode()).isEqualTo(Integer.MIN_VALUE);
|
||||||
|
assertThat(EXPECTED_PALETTE).contains(AppUser.computeColor(minHashId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -902,4 +903,41 @@ class UserServiceTest {
|
|||||||
assertThat(result.getName()).isEqualTo("Familie");
|
assertThat(result.getName()).isEqualTo("Familie");
|
||||||
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
|
||||||
|
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
||||||
|
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
||||||
|
dto.setName("Leser");
|
||||||
|
dto.setPermissions(null);
|
||||||
|
|
||||||
|
UserGroup saved = UserGroup.builder().id(UUID.randomUUID()).name("Leser").build();
|
||||||
|
when(groupRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
userService.createGroup(dto);
|
||||||
|
|
||||||
|
verify(groupRepository).save(argThat(g -> g.getPermissions() != null && g.getPermissions().isEmpty()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
286
docker-compose.prod.yml
Normal file
286
docker-compose.prod.yml
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# 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}
|
||||||
|
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:
|
||||||
@@ -184,10 +231,10 @@ services:
|
|||||||
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ This doc is the Day-1 checklist and operational reference. It links to the canon
|
|||||||
5. [Backup + recovery](#5-backup--recovery)
|
5. [Backup + recovery](#5-backup--recovery)
|
||||||
6. [Common operational tasks](#6-common-operational-tasks)
|
6. [Common operational tasks](#6-common-operational-tasks)
|
||||||
7. [Known limitations](#7-known-limitations)
|
7. [Known limitations](#7-known-limitations)
|
||||||
|
8. [Upgrade notes](#8-upgrade-notes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,20 +28,23 @@ This doc is the Day-1 checklist and operational reference. It links to the canon
|
|||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
Browser -->|HTTPS| Caddy["Caddy (TLS termination)"]
|
Browser -->|HTTPS| Caddy["Caddy (TLS termination)"]
|
||||||
Caddy -->|HTTP :5173| Frontend["Web Frontend\nSvelteKit / Node.js"]
|
Caddy -->|HTTP :3000| Frontend["Web Frontend\nSvelteKit Node adapter"]
|
||||||
Caddy -->|HTTP :8080| Backend["API Backend\nSpring Boot / Jetty :8080"]
|
Caddy -->|HTTP :8080| Backend["API Backend\nSpring Boot / Jetty :8080"]
|
||||||
Backend -->|JDBC :5432| DB[(PostgreSQL 16)]
|
Backend -->|JDBC :5432| DB[(PostgreSQL 16)]
|
||||||
Backend -->|S3 API :9000| MinIO[(MinIO / Hetzner OBS)]
|
Backend -->|S3 API :9000| MinIO[(MinIO)]
|
||||||
Backend -->|HTTP :8000 internal| OCR["OCR Service\nPython FastAPI"]
|
Backend -->|HTTP :8000 internal| OCR["OCR Service\nPython FastAPI"]
|
||||||
OCR -->|presigned URL| MinIO
|
OCR -->|presigned URL| MinIO
|
||||||
Browser -->|SSE direct| Backend
|
Caddy -->|SSE proxy_pass| Backend
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key facts:**
|
**Key facts:**
|
||||||
- Caddy terminates TLS and reverse-proxies to frontend and backend. See the Caddyfile in [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md).
|
- Caddy terminates TLS and reverse-proxies to frontend (`:3000`) and backend (`:8080`). The Caddyfile is committed at [`infra/caddy/Caddyfile`](../infra/caddy/Caddyfile) and is installed on the host as `/etc/caddy/Caddyfile` (symlink).
|
||||||
- The OCR service has **no external port** — reachable only on the internal Docker network from the backend.
|
- The host binds all docker-published ports to `127.0.0.1` only; Caddy is the sole external entry point.
|
||||||
- SSE notifications go directly backend → browser (not via the SvelteKit SSR layer).
|
- The OCR service has **no published port** — reachable only on the internal Docker network from the backend.
|
||||||
- Management port 8081 (Spring Actuator / Prometheus scrape) is internal only — the Caddy config blocks `/actuator/*` externally.
|
- SSE notifications transit Caddy (browser → Caddy → backend); the backend is never reachable directly from the public internet. The SvelteKit SSR layer is bypassed for SSE, but Caddy is not.
|
||||||
|
- The Caddyfile responds `404` on `/actuator/*` (defense in depth). Internal monitoring scrapes the backend on the docker network, not through Caddy.
|
||||||
|
- Production and staging cohabit on the same host via docker compose project names: `archiv-production` (ports 8080/3000) and `archiv-staging` (ports 8081/3001).
|
||||||
|
- An optional observability stack (Prometheus, Node Exporter, cAdvisor, Loki, Tempo, Grafana, GlitchTip) runs as a separate compose file. Configuration lives under `infra/observability/`. In production and CI, the stack is managed from `/opt/familienarchiv/` (CI copies it there on every nightly run) so bind mounts survive workspace wipes — see §4 for the ops procedure.
|
||||||
|
|
||||||
### OCR memory requirements
|
### OCR memory requirements
|
||||||
|
|
||||||
@@ -52,19 +56,23 @@ The OCR service requires significant RAM for model loading. The dev compose sets
|
|||||||
| Hetzner CX32 | 8 GB | 6 GB | Accept reduced batch sizes and slower throughput |
|
| Hetzner CX32 | 8 GB | 6 GB | Accept reduced batch sizes and slower throughput |
|
||||||
| Hetzner CX22 | 4 GB | — | Disable the OCR service (`profiles: [ocr]`); run OCR on demand only |
|
| Hetzner CX22 | 4 GB | — | Disable the OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||||
|
|
||||||
A CX32 cannot honour a `mem_limit: 12g` — set it to `6g` in the prod overlay or use CX42.
|
A CX32 cannot honour the default `mem_limit: 12g` — set the `OCR_MEM_LIMIT=6g` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow) before deploying on a CX32. The prod compose interpolates this var with a 12g default.
|
||||||
|
|
||||||
### Dev vs production differences
|
### Dev vs production differences
|
||||||
|
|
||||||
| Concern | Dev compose | Prod overlay |
|
| Concern | Dev (`docker-compose.yml`) | Prod (`docker-compose.prod.yml`) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| MinIO image tag | `minio/minio:latest` (unpinned) | Pinned in prod overlay |
|
| MinIO image tag | `minio/minio:latest` | Pinned `minio/minio:RELEASE.…` |
|
||||||
| Data persistence | Bind mounts `./data/postgres`, `./data/minio` | Named Docker volumes |
|
| Data persistence | Bind mounts `./data/postgres`, `./data/minio` | Named Docker volumes (`postgres-data`, `minio-data`) |
|
||||||
| Bucket creation | `create-buckets` helper container | Pre-created in Hetzner console |
|
| MinIO credentials for backend | Root user/password | Service account `archiv-app` with bucket-scoped rights |
|
||||||
| Spring profile | `dev,e2e` (enables OpenAPI + Swagger UI) | `prod` |
|
| Bucket creation | `create-buckets` helper | Same helper, plus service-account bootstrap on every up |
|
||||||
| Mail | Mailpit (local catcher) | Real SMTP |
|
| Spring profile | `dev,e2e` (Swagger + e2e overrides) | unset — base `application.yaml` is production-ready |
|
||||||
|
| Mail | Mailpit (local catcher) | Real SMTP (production) / Mailpit via `profiles: [staging]` (staging) |
|
||||||
|
| Frontend image | Dev server, `target: development`, port 5173 | Node adapter, `target: production`, port 3000 |
|
||||||
|
| Host port binding | All published | Bound to `127.0.0.1` only; Caddy is the front door |
|
||||||
|
| Deploy method | `docker compose up -d` (manual) | Gitea Actions: `nightly.yml` (staging, cron) and `release.yml` (production, on `v*` tag) — both use `up -d --wait` |
|
||||||
|
|
||||||
Full prod overlay: [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md).
|
Full prod compose: [`docker-compose.prod.yml`](../docker-compose.prod.yml). Workflow files: [`.gitea/workflows/nightly.yml`](../.gitea/workflows/nightly.yml), [`.gitea/workflows/release.yml`](../.gitea/workflows/release.yml).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,6 +99,7 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
|||||||
| `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — |
|
| `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — |
|
||||||
| `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — |
|
| `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — |
|
||||||
| `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES |
|
| `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES |
|
||||||
|
| `IMPORT_HOST_DIR` | Absolute host path holding the ODS spreadsheet + PDFs for the `/admin/system` mass-import card. Mounted read-only at `/import` inside the backend (compose-only — backend reads via `app.import.dir`). Compose refuses to start when unset, so staging and prod cannot accidentally share the source. Convention: `/srv/familienarchiv-staging/import` and `/srv/familienarchiv-production/import` | — | YES (prod compose) | — |
|
||||||
| `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — |
|
| `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — |
|
||||||
| `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — |
|
| `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — |
|
||||||
| `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES |
|
| `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES |
|
||||||
@@ -99,6 +108,12 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
|||||||
| `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — |
|
| `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — |
|
||||||
| `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — |
|
| `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — |
|
||||||
| `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — |
|
| `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP HTTP endpoint for distributed traces (Tempo). Port 4318 = HTTP transport; port 4317 is gRPC-only and causes "Connection reset" with Spring Boot's HttpExporter. | `http://localhost:4318` | — | — |
|
||||||
|
| `OTEL_LOGS_EXPORTER` | Disable OTLP log export — Promtail captures Docker logs via the logging driver; Tempo does not accept logs. | `none` | — | — |
|
||||||
|
| `OTEL_METRICS_EXPORTER` | Disable OTLP metric export — Prometheus scrapes `/actuator/prometheus` via pull model; Tempo does not accept metrics. | `none` | — | — |
|
||||||
|
| `MANAGEMENT_METRICS_TAGS_APPLICATION` | Common tag added to every Micrometer metric. Required by Grafana's Spring Boot Observability dashboard (ID 17175) `label_values(application)` template variable. | `Familienarchiv` | — | — |
|
||||||
|
| `MANAGEMENT_TRACING_SAMPLING_PROBABILITY` | Micrometer tracing sample rate; overridden to `0.0` in test profile. | `0.1` (compose) / `1.0` (dev) | — | — |
|
||||||
|
| `SENTRY_DSN` | GlitchTip / Sentry DSN for backend error reporting. Leave empty to disable the SDK. Set after GlitchTip first-run (§4). | — | — | YES |
|
||||||
|
|
||||||
### PostgreSQL container
|
### PostgreSQL container
|
||||||
|
|
||||||
@@ -112,9 +127,10 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
|||||||
|
|
||||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `MINIO_ROOT_USER` | MinIO root username | `minio_admin` | YES | — |
|
| `MINIO_ROOT_USER` | MinIO root username (dev compose only — prod compose hardcodes `archiv`) | `minio_admin` | YES (dev) | — |
|
||||||
| `MINIO_ROOT_PASSWORD` | MinIO root password | `change-me` | YES | YES |
|
| `MINIO_ROOT_PASSWORD` / `MINIO_PASSWORD` | MinIO root password. **Used only by the `mc admin` bootstrap in prod, never by the backend.** | `change-me` | YES | YES |
|
||||||
| `MINIO_DEFAULT_BUCKETS` | Bucket name | `archive-documents` | YES | — |
|
| `MINIO_APP_PASSWORD` | Password for the `archiv-app` service account that the backend uses. Bucket-scoped via `readwrite` policy on `familienarchiv`. Bootstrapped by `create-buckets`. | — | YES (prod) | YES |
|
||||||
|
| `MINIO_DEFAULT_BUCKETS` | Bucket name (dev compose only — prod compose hardcodes `familienarchiv`) | `archive-documents` | YES (dev) | — |
|
||||||
|
|
||||||
### OCR service
|
### OCR service
|
||||||
|
|
||||||
@@ -124,58 +140,155 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
|||||||
| `ALLOWED_PDF_HOSTS` | SSRF protection — comma-separated list of allowed PDF source hosts. **Do not widen to `*`** | `minio,localhost,127.0.0.1` | YES | — |
|
| `ALLOWED_PDF_HOSTS` | SSRF protection — comma-separated list of allowed PDF source hosts. **Do not widen to `*`** | `minio,localhost,127.0.0.1` | YES | — |
|
||||||
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
|
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
|
||||||
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
|
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
|
||||||
|
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
|
||||||
|
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
|
||||||
|
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
|
||||||
|
|
||||||
|
### Observability stack (`docker-compose.observability.yml`)
|
||||||
|
|
||||||
|
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `PORT_PROMETHEUS` | Host port for the Prometheus UI (bound to `127.0.0.1` only) | `9090` | — | — |
|
||||||
|
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — |
|
||||||
|
| `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — |
|
||||||
|
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES |
|
||||||
|
| `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — |
|
||||||
|
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — |
|
||||||
|
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES |
|
||||||
|
| `VITE_SENTRY_DSN` | GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite. Leave empty to disable. Set after GlitchTip first-run (§4). | — | — | YES |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Bootstrap from scratch
|
## 3. Bootstrap from scratch
|
||||||
|
|
||||||
> Full VPS provisioning steps are in [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md). This section covers the sequence and the security-critical steps.
|
Production and staging deploy via Gitea Actions (`release.yml` on `v*` tag, `nightly.yml` on cron). The server itself only needs to host Caddy, Docker, and the runner — the workflows handle the rest.
|
||||||
|
|
||||||
### Security checklist — complete before first boot
|
### 3.1 Server one-time setup
|
||||||
|
|
||||||
> ⚠️ **These defaults ship in `.env.example` and `application.yaml`. Change them or you will have an insecure installation.**
|
|
||||||
|
|
||||||
- [ ] Set `APP_ADMIN_PASSWORD` (default: `admin123` — change before starting the backend)
|
|
||||||
- [ ] Set `APP_ADMIN_USERNAME` if you want a non-default admin login name (add to `.env` — not in `.env.example`)
|
|
||||||
- [ ] Rotate `POSTGRES_PASSWORD` from `change-me`
|
|
||||||
- [ ] Rotate `MINIO_ROOT_PASSWORD` from `change-me`
|
|
||||||
- [ ] Set a strong `APP_OCR_TRAINING_TOKEN` (backend) and the matching `TRAINING_TOKEN` (OCR service) — both must be the same value (`python3 -c "import secrets; print(secrets.token_hex(32))"`)
|
|
||||||
- [ ] Confirm `ALLOWED_PDF_HOSTS` is locked to your MinIO/S3 hostname — widening to `*` opens SSRF
|
|
||||||
- [ ] Set `SPRING_PROFILES_ACTIVE=prod` in the prod overlay (not `dev,e2e` — that exposes Swagger UI and `/v3/api-docs`)
|
|
||||||
- [ ] Use a dedicated MinIO service account for `S3_ACCESS_KEY` / `S3_SECRET_KEY`, not the root credentials
|
|
||||||
|
|
||||||
### Bootstrap sequence
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Copy and fill the env file
|
# Base hardening
|
||||||
cp .env.example .env
|
ufw default deny incoming && ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw enable
|
||||||
# edit .env — complete the security checklist above first
|
# /etc/ssh/sshd_config: PasswordAuthentication no, PermitRootLogin no
|
||||||
|
|
||||||
# 2. (Production only) Create the MinIO / Hetzner OBS bucket in the console
|
# Install Caddy 2 (https://caddyserver.com/docs/install#debian-ubuntu-raspbian)
|
||||||
# The dev compose has a create-buckets helper; production does not.
|
apt install caddy
|
||||||
# Create the bucket named $MINIO_DEFAULT_BUCKETS with private access.
|
|
||||||
|
|
||||||
# 3. Start the stack (prod overlay — see docs/infrastructure/production-compose.md)
|
# Use the Caddyfile from the repo (replace path with the runner's clone target)
|
||||||
# docker-compose.prod.yml is NOT committed — create it from the guide above
|
# CI DEPENDENCY: the nightly and release workflows run `systemctl reload caddy` to
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
# pick up committed Caddyfile changes. They find the file via this symlink — if it
|
||||||
|
# is absent or points elsewhere, the reload succeeds but serves stale config.
|
||||||
|
ln -sf /opt/familienarchiv/infra/caddy/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
systemctl reload caddy
|
||||||
|
|
||||||
# 4. Flyway migrations run automatically on backend start.
|
# fail2ban — protect /api/auth/login from credential stuffing.
|
||||||
# Watch the backend log to confirm:
|
# Jail watches the Caddy JSON access log for 401 responses on
|
||||||
docker compose logs --follow --tail=100 backend
|
# /api/auth/login. The jail (maxretry=10 / findtime=10m / bantime=30m)
|
||||||
|
# and filter are committed under infra/fail2ban/ — symlink them in:
|
||||||
|
apt install fail2ban
|
||||||
|
ln -sf /opt/familienarchiv/infra/fail2ban/jail.d/familienarchiv.conf \
|
||||||
|
/etc/fail2ban/jail.d/familienarchiv.conf
|
||||||
|
ln -sf /opt/familienarchiv/infra/fail2ban/filter.d/familienarchiv-auth.conf \
|
||||||
|
/etc/fail2ban/filter.d/familienarchiv-auth.conf
|
||||||
|
systemctl reload fail2ban
|
||||||
|
# Verify after first deploy with:
|
||||||
|
# fail2ban-client status familienarchiv-auth
|
||||||
|
# fail2ban-regex /var/log/caddy/access.log familienarchiv-auth
|
||||||
|
|
||||||
# 5. Verify the stack is healthy
|
# Tailscale — used by the backup pipeline to reach heim-nas (follow-up issue)
|
||||||
curl http://localhost:8080/actuator/health
|
curl -fsSL https://tailscale.com/install.sh | sh && tailscale up
|
||||||
# Expected: {"status":"UP"}
|
|
||||||
|
|
||||||
# 6. Open the app and log in with the admin credentials from .env
|
# Self-hosted Gitea runner — register against the repo with a runner token.
|
||||||
|
# This runner is assumed single-tenant: the deploy workflows write .env.*
|
||||||
|
# files to disk during execution (cleaned up unconditionally on completion).
|
||||||
|
# A multi-tenant runner would need to switch to stdin-piped env files.
|
||||||
|
# (See https://docs.gitea.com/usage/actions/quickstart for the register step.)
|
||||||
|
|
||||||
|
# Runner workspace directory — required for DooD bind-mount resolution (ADR-015).
|
||||||
|
# act_runner stores job workspaces here so that docker compose bind mounts resolve
|
||||||
|
# to real host paths. The path must be identical on the host and inside job containers.
|
||||||
|
mkdir -p /srv/gitea-workspace
|
||||||
|
# Observability config permanent directory — the nightly CI job copies
|
||||||
|
# docker-compose.observability.yml and infra/observability/ here on every run.
|
||||||
|
# The obs stack is always started from this path, not from the workspace.
|
||||||
|
# See ADR-016 for why this directory is used instead of a server-pull approach.
|
||||||
|
mkdir -p /opt/familienarchiv/infra
|
||||||
|
# Both paths must also appear in the runner service volumes in ~/docker/gitea/compose.yaml:
|
||||||
|
# volumes:
|
||||||
|
# - /srv/gitea-workspace:/srv/gitea-workspace
|
||||||
|
# /opt/familienarchiv does NOT need to be in the runner container's volumes — job
|
||||||
|
# containers are spawned by the host daemon directly (DooD), so the host path is
|
||||||
|
# accessible to them as long as runner-config.yaml lists it in valid_volumes + options.
|
||||||
|
# See runner-config.yaml (workdir_parent + valid_volumes + options) and ADR-015/016.
|
||||||
|
|
||||||
|
# ⚠ IMPORTANT: after any change to runner-config.yaml (valid_volumes, options, workdir_parent),
|
||||||
|
# restart the Gitea Act runner for the new config to take effect:
|
||||||
|
# docker restart gitea-runner
|
||||||
|
# Until restarted, job containers are spawned with the old config and any new bind mounts
|
||||||
|
# (e.g. /opt/familienarchiv) will not be available inside job steps.
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Do not use `docker-compose.ci.yml` locally** — it disables bind mounts that the dev workflow depends on.
|
### 3.2 DNS records
|
||||||
|
|
||||||
|
```
|
||||||
|
archiv.raddatz.cloud A <server IP>
|
||||||
|
staging.raddatz.cloud A <server IP>
|
||||||
|
git.raddatz.cloud A <server IP>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Gitea secrets (Repo → Settings → Actions → Secrets)
|
||||||
|
|
||||||
|
| Secret | Used by | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `PROD_POSTGRES_PASSWORD` | release.yml | strong unique password |
|
||||||
|
| `PROD_MINIO_PASSWORD` | release.yml | MinIO root password; used only at bootstrap |
|
||||||
|
| `PROD_MINIO_APP_PASSWORD` | release.yml | application service-account password |
|
||||||
|
| `PROD_OCR_TRAINING_TOKEN` | release.yml | `python3 -c "import secrets; print(secrets.token_hex(32))"` |
|
||||||
|
| `PROD_APP_ADMIN_USERNAME` | release.yml | e.g. `admin@archiv.raddatz.cloud` |
|
||||||
|
| `PROD_APP_ADMIN_PASSWORD` | release.yml | **⚠ locked permanently on first deploy** — see §3.5 |
|
||||||
|
| `STAGING_POSTGRES_PASSWORD` | nightly.yml | different from prod |
|
||||||
|
| `STAGING_MINIO_PASSWORD` | nightly.yml | different from prod |
|
||||||
|
| `STAGING_MINIO_APP_PASSWORD` | nightly.yml | different from prod |
|
||||||
|
| `STAGING_OCR_TRAINING_TOKEN` | nightly.yml | different from prod |
|
||||||
|
| `STAGING_APP_ADMIN_USERNAME` | nightly.yml | e.g. `admin@staging.raddatz.cloud` |
|
||||||
|
| `STAGING_APP_ADMIN_PASSWORD` | nightly.yml | locked on first staging deploy |
|
||||||
|
| `MAIL_HOST` | release.yml | SMTP relay hostname (prod only) |
|
||||||
|
| `MAIL_PORT` | release.yml | typically `587` |
|
||||||
|
| `MAIL_USERNAME` | release.yml | SMTP user |
|
||||||
|
| `MAIL_PASSWORD` | release.yml | SMTP password |
|
||||||
|
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
|
||||||
|
| `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` |
|
||||||
|
| `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
|
||||||
|
| `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
|
||||||
|
|
||||||
|
### 3.4 First deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
|
||||||
|
# Expected: docker compose up -d --wait succeeds for archiv-staging, then
|
||||||
|
# the workflow's "Smoke test deployed environment" step asserts:
|
||||||
|
# - https://staging.raddatz.cloud/login returns 200
|
||||||
|
# - HSTS header is present
|
||||||
|
# - /actuator/health returns 404 (defense-in-depth check)
|
||||||
|
# 2. (Optional) Re-verify manually
|
||||||
|
curl -I https://staging.raddatz.cloud/
|
||||||
|
# Expected: 200 (login page) with HSTS + X-Content-Type-Options headers
|
||||||
|
# 3. When staging looks healthy, push a v* tag to trigger release.yml
|
||||||
|
git tag v1.0.0 && git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 ⚠ Admin password is locked on first deploy
|
||||||
|
|
||||||
|
`UserDataInitializer` creates the admin user **only if the email does not exist**. The first successful deploy persists the admin password to the database. Changing `PROD_APP_ADMIN_PASSWORD` in Gitea secrets after that point has **no effect** — the secret is only consulted when the row is missing.
|
||||||
|
|
||||||
|
Before the first deploy: rotate `PROD_APP_ADMIN_PASSWORD` to a strong value. After the first deploy: change the admin password via the in-app account settings, not via the Gitea secret.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Logs + observability
|
## 4. Logs + observability
|
||||||
|
|
||||||
|
> **Developer guide (where to look for what, LogQL queries, trace exploration) → [docs/OBSERVABILITY.md](./OBSERVABILITY.md).**
|
||||||
|
> This section covers the ops side: starting the stack, env vars, and CI wiring.
|
||||||
|
|
||||||
### First-response commands
|
### First-response commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -196,9 +309,156 @@ docker compose logs --tail=200 <service>
|
|||||||
- **Spring Actuator health**: `http://localhost:8080/actuator/health` (internal only in prod — port 8081 for Prometheus scraping)
|
- **Spring Actuator health**: `http://localhost:8080/actuator/health` (internal only in prod — port 8081 for Prometheus scraping)
|
||||||
- **Prometheus scraping**: management port 8081, path `/actuator/prometheus`. Internal only; Caddy blocks `/actuator/*` externally.
|
- **Prometheus scraping**: management port 8081, path `/actuator/prometheus`. Internal only; Caddy blocks `/actuator/*` externally.
|
||||||
|
|
||||||
### Future observability
|
### Observability stack
|
||||||
|
|
||||||
Phase 7 of the Production v1 milestone adds Prometheus + Loki + Grafana. No monitoring infrastructure is in place yet.
|
An observability stack is available via `docker-compose.observability.yml`. Configuration lives under `infra/observability/`.
|
||||||
|
|
||||||
|
#### Dev — start from the workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # creates archiv-net
|
||||||
|
docker compose -f docker-compose.observability.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Why the obs stack is managed differently from the main app stack
|
||||||
|
|
||||||
|
The main app stack (`docker-compose.prod.yml`) has no config-file bind mounts — its containers read config from env vars and image defaults. The workspace is wiped after each CI run but that does not affect running containers, because they hold no references to workspace paths.
|
||||||
|
|
||||||
|
The obs stack is different: `prometheus.yml`, `tempo.yml`, Loki config, Grafana provisioning files, and Promtail config are all bind-mounted from the host filesystem into their containers. If those source paths disappear (workspace wipe), the containers can restart fine until a `docker compose up` is run again — at that point Docker tries to re-resolve the bind-mount source and fails because the workspace path no longer exists.
|
||||||
|
|
||||||
|
The fix is to keep the obs compose file and config tree at a **permanent path** that CI copies to on every run but which survives between runs: `/opt/familienarchiv/` (see ADR-016).
|
||||||
|
|
||||||
|
#### Production — managed from `/opt/familienarchiv/`
|
||||||
|
|
||||||
|
Every CI run (nightly + release) copies `docker-compose.observability.yml` and `infra/observability/` to `/opt/familienarchiv/` before starting the stack. Bind mounts then resolve to `/opt/familienarchiv/infra/observability/…` — a stable path that outlasts any workspace wipe.
|
||||||
|
|
||||||
|
**Environment variables** follow the same two-source model as the main stack:
|
||||||
|
|
||||||
|
| Source | What it contains | Managed by |
|
||||||
|
|---|---|---|
|
||||||
|
| `infra/observability/obs.env` | All non-secret config (ports, URLs, hostnames) | Git — reviewed in PRs |
|
||||||
|
| `/opt/familienarchiv/obs-secrets.env` | Passwords and secret keys only | CI — written fresh from Gitea secrets on every deploy |
|
||||||
|
|
||||||
|
Both files are passed explicitly via `--env-file` to the compose command, so there is no implicit auto-read `.env` and no operator-managed file to keep in sync.
|
||||||
|
|
||||||
|
**Non-secret config** (`infra/observability/obs.env`):
|
||||||
|
|
||||||
|
| Key | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT_GRAFANA` | `3003` | Avoids collision with staging frontend on port 3001 |
|
||||||
|
| `PORT_GLITCHTIP` | `3002` | |
|
||||||
|
| `PORT_PROMETHEUS` | `9090` | |
|
||||||
|
| `GF_SERVER_ROOT_URL` | `https://grafana.archiv.raddatz.cloud` | Required for alert email links and OAuth redirects |
|
||||||
|
| `GLITCHTIP_DOMAIN` | `https://glitchtip.archiv.raddatz.cloud` | Must match the Caddy vhost |
|
||||||
|
| `POSTGRES_HOST` | `archive-db` | Override if only the staging stack is running |
|
||||||
|
|
||||||
|
**Secret keys** (set in Gitea secrets, injected by CI into `obs-secrets.env`):
|
||||||
|
|
||||||
|
| Gitea secret | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
|
||||||
|
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
|
||||||
|
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |
|
||||||
|
|
||||||
|
To start or restart the obs stack manually on the server (after CI has run at least once):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note (manual ops only):** CI clears the destination with `rm -rf` before copying, so deleted files are removed automatically on the next run. If you copy manually with `cp -r` without first removing the directory, stale files from deleted configs will persist until cleaned up:
|
||||||
|
> ```bash
|
||||||
|
> rm /opt/familienarchiv/infra/observability/<path-to-removed-file>
|
||||||
|
> ```
|
||||||
|
|
||||||
|
Current services:
|
||||||
|
|
||||||
|
| Service | Image | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `obs-prometheus` | `prom/prometheus:v3.4.0` | Scrapes metrics from backend management port 8081 (`/actuator/prometheus`), node-exporter, and cAdvisor |
|
||||||
|
| `obs-node-exporter` | `prom/node-exporter:v1.9.0` | Host-level CPU / memory / disk / network metrics |
|
||||||
|
| `obs-cadvisor` | `gcr.io/cadvisor/cadvisor:v0.52.1` | Per-container resource metrics |
|
||||||
|
| `obs-loki` | `grafana/loki:3.4.2` | Log aggregation — receives log streams from Promtail. Port 3100 is `expose`-only (not host-bound). |
|
||||||
|
| `obs-promtail` | `grafana/promtail:3.4.2` | Log shipping agent — reads all Docker container logs via the Docker socket and forwards them to Loki with `container_name`, `compose_service`, `compose_project`, and `job` labels. The `job` label is mapped from the Docker Compose service name (`com.docker.compose.service`) so that Grafana Loki dashboard queries (`{job="backend"}`, `{job="frontend"}`) work out of the box and the "App" variable dropdown is populated. |
|
||||||
|
| `obs-tempo` | `grafana/tempo:2.7.2` | Distributed trace storage — OTLP HTTP receiver on port 4318 (`archiv-net`-internal; backend sends traces here). Grafana queries traces on port 3200 (`obs-net`-internal). All ports are `expose`-only (not host-bound). |
|
||||||
|
| `obs-grafana` | `grafana/grafana-oss:11.6.1` | Unified observability UI — metrics dashboards, log exploration, trace viewer. Bound to `127.0.0.1:${PORT_GRAFANA:-3003}` on the host. |
|
||||||
|
| `obs-glitchtip` | `glitchtip/glitchtip:6.1.6` | Sentry-compatible error tracker. Receives frontend + backend error events, groups by fingerprint, provides issue UI with stack traces. Bound to `127.0.0.1:${PORT_GLITCHTIP:-3002}`. |
|
||||||
|
| `obs-glitchtip-worker` | `glitchtip/glitchtip:6.1.6` | Celery + beat worker — processes async GlitchTip tasks (event ingestion, notifications, cleanup). |
|
||||||
|
| `obs-redis` | `redis:7-alpine` | Celery task broker for GlitchTip. Internal to `obs-net`; no host port exposed. |
|
||||||
|
| `obs-glitchtip-db-init` | `postgres:16-alpine` | One-shot init container. Creates the `glitchtip` database on the existing `archive-db` PostgreSQL instance if it does not already exist. Runs at stack startup; exits cleanly once done. |
|
||||||
|
|
||||||
|
#### Grafana
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| URL | `http://localhost:3003` (or `http://localhost:$PORT_GRAFANA`) |
|
||||||
|
| Username | `admin` |
|
||||||
|
| Password | `$GRAFANA_ADMIN_PASSWORD` (default: `changeme` — **change before exposing to a network**) |
|
||||||
|
|
||||||
|
Datasources are auto-provisioned on first start (Prometheus, Loki, Tempo — no manual setup required). Three dashboards are pre-loaded:
|
||||||
|
|
||||||
|
| Dashboard | Grafana ID | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Node Exporter Full | 1860 | Host CPU, memory, disk, network |
|
||||||
|
| Spring Boot Observability | 17175 | JVM metrics, HTTP latency, error rate |
|
||||||
|
| Loki Logs | 13639 | Log exploration and filtering |
|
||||||
|
|
||||||
|
Tempo traces are accessible via Grafana Explore → Tempo datasource, and linked from Loki logs via the `traceId` derived field.
|
||||||
|
|
||||||
|
**Loki quick checks** (after ~60 s, run from inside the `obs-loki` container):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Loki health
|
||||||
|
docker exec obs-loki wget -qO- http://localhost:3100/ready
|
||||||
|
|
||||||
|
# List labels
|
||||||
|
docker exec obs-loki wget -qO- 'http://localhost:3100/loki/api/v1/labels'
|
||||||
|
|
||||||
|
# Query logs by service (stable across dev and prod environments)
|
||||||
|
docker exec obs-loki wget -qO- \
|
||||||
|
'http://localhost:3100/loki/api/v1/query_range?query=%7Bcompose_service%3D%22backend%22%7D&limit=5'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer `compose_service` over `container_name` in LogQL queries** — `container_name` differs between dev (`archive-backend`) and prod (`archiv-production-backend-1`), while `compose_service` is stable (`backend`, `db`, `minio`, etc.).
|
||||||
|
|
||||||
|
Prometheus port `9090` and Grafana port `3003` (default; configurable via `PORT_GRAFANA`) are bound to `127.0.0.1` on the host. No other observability ports are host-bound.
|
||||||
|
|
||||||
|
#### GlitchTip
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| URL | `http://localhost:3002` (or `http://localhost:$PORT_GLITCHTIP`) |
|
||||||
|
|
||||||
|
**Required env vars** — set in `.env` before first start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLITCHTIP_SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
GLITCHTIP_DOMAIN=http://localhost:3002 # change to your public URL in prod
|
||||||
|
PORT_GLITCHTIP=3002 # optional, defaults to 3002
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database:** GlitchTip shares the existing `archive-db` PostgreSQL instance. The `obs-glitchtip-db-init` one-shot container creates a dedicated `glitchtip` database on first stack start — no manual step required.
|
||||||
|
|
||||||
|
**First-run steps** (one-time, after `docker compose -f docker-compose.observability.yml up -d`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create the Django superuser (interactive)
|
||||||
|
docker exec -it obs-glitchtip ./manage.py createsuperuser
|
||||||
|
|
||||||
|
# 2. Open the GlitchTip UI and log in
|
||||||
|
open http://localhost:3002
|
||||||
|
|
||||||
|
# 3. Create an organisation (e.g. "Familienarchiv")
|
||||||
|
# 4. Create two projects:
|
||||||
|
# - "familienarchiv-frontend" (platform: JavaScript / SvelteKit)
|
||||||
|
# - "familienarchiv-backend" (platform: Java / Spring Boot)
|
||||||
|
# 5. Copy each project's DSN from Settings → Projects → <project> → Client Keys
|
||||||
|
# 6. Wire the DSNs into the backend and frontend via env vars (separate issue)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -224,7 +484,23 @@ docker exec -i archive-db psql -U ${POSTGRES_USER} ${POSTGRES_DB} < backup-YYYYM
|
|||||||
|
|
||||||
### Planned — phase 5 of Production v1 milestone
|
### Planned — phase 5 of Production v1 milestone
|
||||||
|
|
||||||
Automated backup (PostgreSQL WAL archiving + MinIO bucket replication) is planned in the Production v1 milestone phase 5. Until that ships: **manual backups are the only recovery option.**
|
Automated backup (nightly `pg_dump` + MinIO `mc mirror` over Tailscale to `heim-nas`) is a follow-up issue. Until that ships: **manual backups are the only recovery option.**
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TAG=v1.0.0 docker compose \
|
||||||
|
-f docker-compose.prod.yml \
|
||||||
|
-p archiv-production \
|
||||||
|
--env-file /opt/familienarchiv/.env.production \
|
||||||
|
up -d --wait --remove-orphans
|
||||||
|
```
|
||||||
|
|
||||||
|
If the rollback target image is no longer present on the host (host disk pruned, etc.), re-trigger `release.yml` for that tag from Gitea Actions UI — it rebuilds and redeploys.
|
||||||
|
|
||||||
|
**Flyway migrations are not auto-rolled-back.** If a release contained a destructive migration (drop column, rename table), a tag rollback brings the schema back to a previous app version but the data shape has already changed. For breaking schema changes, prefer a forward-only fix.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -257,9 +533,18 @@ bash scripts/download-kraken-models.sh
|
|||||||
|
|
||||||
### Trigger a mass import (Excel/ODS)
|
### Trigger a mass import (Excel/ODS)
|
||||||
|
|
||||||
1. Place the import file in the `import/` bind mount on the backend container.
|
**Dev:** drop the ODS spreadsheet + PDFs into `./import/` at the repo root — the dev compose bind-mounts it to `/import` automatically.
|
||||||
2. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission).
|
|
||||||
3. The import runs asynchronously — poll `GET /api/admin/import-status` or watch backend logs.
|
**Staging/production:**
|
||||||
|
|
||||||
|
1. Pre-stage the payload on the host. Convention: `/srv/familienarchiv-staging/import/` or `/srv/familienarchiv-production/import/`.
|
||||||
|
```bash
|
||||||
|
rsync -avh --progress ./import/ user@host:/srv/familienarchiv-staging/import/
|
||||||
|
```
|
||||||
|
2. Make sure `IMPORT_HOST_DIR=<host-path>` is set in `.env.staging` / `.env.production` (the nightly/release workflows already write this — see §3). Compose refuses to start without it.
|
||||||
|
3. Redeploy the stack so the bind mount picks up — or, if the mount is already in place, skip to step 4.
|
||||||
|
4. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission), or click the "Import starten" button on `/admin/system`.
|
||||||
|
5. The import runs asynchronously — poll `GET /api/admin/import-status`, watch `/admin/system`, or tail the backend logs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,3 +557,44 @@ bash scripts/download-kraken-models.sh
|
|||||||
| **No multi-region** | Single PostgreSQL + MinIO instance; no replication or failover | Deliberate scope decision |
|
| **No multi-region** | Single PostgreSQL + MinIO instance; no replication or failover | Deliberate scope decision |
|
||||||
| **Max upload size** | 50 MB per file (500 MB per request for multi-file) | Configurable in `application.yaml` (`spring.servlet.multipart`) |
|
| **Max upload size** | 50 MB per file (500 MB per request for multi-file) | Configurable in `application.yaml` (`spring.servlet.multipart`) |
|
||||||
| **No automated backup** | Phase 5 of Production v1 milestone is not yet implemented | See §5 above |
|
| **No automated backup** | Phase 5 of Production v1 milestone is not yet implemented | See §5 above |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Upgrade notes
|
||||||
|
|
||||||
|
Version-specific one-time steps that must be run before or after upgrading to a given release. Each subsection is safe to skip on a fresh install.
|
||||||
|
|
||||||
|
### Upgrading to PR #615 — TMPDIR redirect + ocr-volume-init
|
||||||
|
|
||||||
|
`ocr-volume-init` is a new one-shot service in both compose files that runs before `ocr-service` on every `docker compose up`. It:
|
||||||
|
|
||||||
|
1. `chown -R 1000:1000 /app/cache /app/models` — corrects volume ownership so the non-root `ocr` user (uid 1000) can write to volumes that may have been created as root (including volumes from before PR #611).
|
||||||
|
2. `mkdir -p /app/cache/.tmp` — creates the TMPDIR staging directory that Surya uses for GB-scale model downloads. Without this directory, the first model download falls back to the 512 MB `/tmp` tmpfs and fails with ENOSPC. See ADR-021.
|
||||||
|
|
||||||
|
**Verify it succeeded:**
|
||||||
|
```bash
|
||||||
|
docker logs archiv-ocr-volume-init # dev
|
||||||
|
docker logs archiv-production-ocr-volume-init-1 # prod
|
||||||
|
```
|
||||||
|
Expected output: no error lines; exit code 0.
|
||||||
|
|
||||||
|
**Failure mode:** if `chown` is denied (e.g. the volume is mounted read-only), the container exits non-zero and `ocr-service` will not start (`depends_on: condition: service_completed_successfully`). Check `docker logs` for the `chown` error and verify the volume is writable.
|
||||||
|
|
||||||
|
### Upgrading to PR #611 — non-root OCR container
|
||||||
|
|
||||||
|
The OCR cache volume path changed from `/root/.cache` to `/app/cache` (PR #611 — CIS Docker §4.1 hardening). The existing volume was written as root and is inaccessible to the new non-root `ocr` user, causing a `PermissionError` on startup.
|
||||||
|
|
||||||
|
**Before starting the updated container stack**, drop the old root-owned volume. The volume name depends on the compose project name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev (docker-compose.yml — project name: familienarchiv)
|
||||||
|
docker volume rm familienarchiv_ocr_cache
|
||||||
|
|
||||||
|
# Production (docker-compose.prod.yml -p archiv-production)
|
||||||
|
docker volume rm archiv-production_ocr-cache
|
||||||
|
|
||||||
|
# Staging (docker-compose.prod.yml -p archiv-staging)
|
||||||
|
docker volume rm archiv-staging_ocr-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
The volume is recreated automatically on `docker compose up`. The OCR service will re-download its model cache on first startup (approximately 1–2 GB, one-time cost).
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ _See also [Briefwechsel](#briefwechsel-user-facing)._
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Infrastructure Terms
|
||||||
|
|
||||||
|
**archiv-app** — the bucket-scoped MinIO service account the backend uses to read and write the `familienarchiv` bucket. Distinct from the MinIO root account (`archiv`, used only by the bootstrap container for admin operations). Defined and provisioned in [`infra/minio/bootstrap.sh`](../infra/minio/bootstrap.sh) and consumed by the backend as `S3_ACCESS_KEY` in [`docker-compose.prod.yml`](../docker-compose.prod.yml). The attached `archiv-app-policy` grants `s3:GetObject/PutObject/DeleteObject` on `familienarchiv/*` and `s3:ListBucket/GetBucketLocation` on the bucket only — not the built-in `readwrite` policy which would grant `s3:*` on all buckets.
|
||||||
|
_See also [ADR-010 — MinIO stays self-hosted, not Hetzner OBS](./adr/010-minio-self-hosted-not-hetzner-obs.md)._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Pending Terms
|
## Pending Terms
|
||||||
|
|
||||||
_Terms flagged as potentially ambiguous that have not yet been formally defined here. Add an entry above and remove it from this list when resolved._
|
_Terms flagged as potentially ambiguous that have not yet been formally defined here. Add an entry above and remove it from this list when resolved._
|
||||||
|
|||||||
180
docs/OBSERVABILITY.md
Normal file
180
docs/OBSERVABILITY.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Observability Guide
|
||||||
|
|
||||||
|
> **Ops reference (starting the stack, env vars, CI wiring) → [DEPLOYMENT.md §4](./DEPLOYMENT.md#4-logs--observability).**
|
||||||
|
> This file is for developers: what signal lives where, how to reach it, and what to look for.
|
||||||
|
|
||||||
|
## Where to look for what
|
||||||
|
|
||||||
|
| I want to… | Go to |
|
||||||
|
|---|---|
|
||||||
|
| See the last N log lines from the backend | `docker compose logs --tail=100 backend` |
|
||||||
|
| Search logs by keyword across time | Grafana → Explore → Loki |
|
||||||
|
| Understand why an HTTP request failed | Grafana → Explore → Loki → filter by `traceId` → follow link to Tempo |
|
||||||
|
| See a full distributed trace (DB queries, HTTP calls) | Grafana → Explore → Tempo → search by service or trace ID |
|
||||||
|
| Check JVM heap / GC / thread count | Grafana → Dashboards → Spring Boot Observability |
|
||||||
|
| Check HTTP error rate or p95 latency | Grafana → Dashboards → Spring Boot Observability |
|
||||||
|
| Check host CPU / memory / disk | Grafana → Dashboards → Node Exporter Full |
|
||||||
|
| See grouped application errors with stack traces | GlitchTip |
|
||||||
|
| Check if the backend is healthy | `curl http://localhost:8081/actuator/health` (on the server) |
|
||||||
|
| Check what Prometheus is scraping | `curl http://localhost:9090/api/v1/targets` (on the server) |
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
| Tool | External URL | Who it's for |
|
||||||
|
|---|---|---|
|
||||||
|
| Grafana | `https://grafana.archiv.raddatz.cloud` | Logs, metrics, traces — the primary observability UI |
|
||||||
|
| GlitchTip | `https://glitchtip.archiv.raddatz.cloud` | Grouped errors with stack traces and release tracking |
|
||||||
|
|
||||||
|
Loki, Tempo, and Prometheus have no external URL. They are internal services, accessible only through Grafana (or via SSH tunnel — see below).
|
||||||
|
|
||||||
|
## Logs (Loki)
|
||||||
|
|
||||||
|
Logs reach Loki via Promtail, which reads all Docker container logs from the Docker socket and ships them with labels derived from Docker Compose metadata.
|
||||||
|
|
||||||
|
### Labels available in every log line
|
||||||
|
|
||||||
|
| Label | What it contains | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| `job` | Compose service name | `backend`, `frontend`, `db` |
|
||||||
|
| `compose_service` | Same as `job` | `backend` |
|
||||||
|
| `compose_project` | Compose project name | `archiv-staging`, `archiv-production` |
|
||||||
|
| `container_name` | Docker container name | `archiv-staging-backend-1` |
|
||||||
|
| `filename` | Docker log source | `/var/lib/docker/containers/…` |
|
||||||
|
|
||||||
|
**Use `job` in LogQL queries** — it is stable across dev, staging, and production. `container_name` changes between environments.
|
||||||
|
|
||||||
|
### Common LogQL queries in Grafana Explore
|
||||||
|
|
||||||
|
```logql
|
||||||
|
# All backend logs
|
||||||
|
{job="backend"}
|
||||||
|
|
||||||
|
# Backend ERROR and WARN lines only
|
||||||
|
{job="backend"} |= "ERROR" or {job="backend"} |= "WARN"
|
||||||
|
|
||||||
|
# All logs for a specific request (paste a traceId from a log line)
|
||||||
|
{job="backend"} |= "3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||||
|
|
||||||
|
# Log lines containing a specific exception class
|
||||||
|
{job="backend"} |~ "DomainException|NullPointerException"
|
||||||
|
|
||||||
|
# Frontend logs
|
||||||
|
{job="frontend"}
|
||||||
|
|
||||||
|
# Database (slow query log, if enabled)
|
||||||
|
{job="db"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log → Trace correlation
|
||||||
|
|
||||||
|
Spring Boot writes the active `traceId` into every log line when a request is being processed:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-05-16 ... INFO [Familienarchiv,3fa85f64...,1b2c3d4e] o.r.f.document.DocumentService : ...
|
||||||
|
```
|
||||||
|
|
||||||
|
In Grafana Explore → Loki, log lines with a `traceId` field show a **Tempo** link. Clicking it opens the full trace in Explore → Tempo without copying and pasting IDs.
|
||||||
|
|
||||||
|
This linking is configured in the Loki datasource provisioning via the `traceId` derived field regex. No manual setup required.
|
||||||
|
|
||||||
|
## Traces (Tempo)
|
||||||
|
|
||||||
|
The backend sends traces to Tempo via OTLP HTTP (port 4318). Every inbound HTTP request and every JPA query produces a span. Spans are linked into traces by the propagated `traceId` header.
|
||||||
|
|
||||||
|
### Finding a trace in Grafana
|
||||||
|
|
||||||
|
**Option A — from a log line:**
|
||||||
|
1. Grafana → Explore → select *Loki* datasource
|
||||||
|
2. Query `{job="backend"}` and find the failing request
|
||||||
|
3. Click the **Tempo** link in the log line (appears when `traceId` is present)
|
||||||
|
|
||||||
|
**Option B — by service:**
|
||||||
|
1. Grafana → Explore → select *Tempo* datasource
|
||||||
|
2. Query type: **Search**
|
||||||
|
3. Service name: `familienarchiv-backend`
|
||||||
|
4. Filter by HTTP status, duration, or operation name as needed
|
||||||
|
|
||||||
|
**Option C — by trace ID:**
|
||||||
|
1. Grafana → Explore → select *Tempo* datasource
|
||||||
|
2. Query type: **TraceQL** or **Trace ID**
|
||||||
|
3. Paste the trace ID
|
||||||
|
|
||||||
|
### What each span type tells you
|
||||||
|
|
||||||
|
| Root span name pattern | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/documents`, `POST /api/documents` | Full HTTP request lifecycle |
|
||||||
|
| `SELECT archiv.*` | A single JPA/JDBC query inside that request |
|
||||||
|
| `HikariPool.getConnection` | Connection pool wait time |
|
||||||
|
|
||||||
|
A slow `SELECT` span inside an otherwise fast HTTP span pinpoints a missing index. A slow `HikariPool.getConnection` span indicates connection pool exhaustion.
|
||||||
|
|
||||||
|
### Sampling rate
|
||||||
|
|
||||||
|
- **Dev**: 100% of requests are traced (`management.tracing.sampling.probability: 1.0` in `application.yaml`)
|
||||||
|
- **Staging / Production**: 10% (`MANAGEMENT_TRACING_SAMPLING_PROBABILITY=0.1` in `docker-compose.prod.yml`)
|
||||||
|
|
||||||
|
To find a trace for a specific request in staging/production, either increase the sampling rate temporarily or trigger the request multiple times.
|
||||||
|
|
||||||
|
## Metrics (Prometheus → Grafana)
|
||||||
|
|
||||||
|
Prometheus scrapes the backend management endpoint every 15 s:
|
||||||
|
|
||||||
|
```
|
||||||
|
Target: backend:8081/actuator/prometheus
|
||||||
|
Labels: job="spring-boot", application="Familienarchiv"
|
||||||
|
```
|
||||||
|
|
||||||
|
All Spring Boot metrics carry the `application="Familienarchiv"` tag, which is how the Grafana Spring Boot Observability dashboard (ID 17175) filters to this service.
|
||||||
|
|
||||||
|
### Useful Prometheus queries (run on the server or via Grafana Explore → Prometheus)
|
||||||
|
|
||||||
|
```promql
|
||||||
|
# HTTP error rate (5xx) as a fraction of all requests
|
||||||
|
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
|
||||||
|
/ sum(rate(http_server_requests_seconds_count[5m]))
|
||||||
|
|
||||||
|
# p95 response time
|
||||||
|
histogram_quantile(0.95, sum by (le) (
|
||||||
|
rate(http_server_requests_seconds_bucket[5m])
|
||||||
|
))
|
||||||
|
|
||||||
|
# JVM heap used
|
||||||
|
jvm_memory_used_bytes{area="heap", application="Familienarchiv"}
|
||||||
|
|
||||||
|
# Active DB connections
|
||||||
|
hikaricp_connections_active
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors (GlitchTip)
|
||||||
|
|
||||||
|
GlitchTip receives errors from both the backend (via Sentry Java SDK) and the frontend (via Sentry JavaScript SDK). It groups events by fingerprint, tracks first/last seen times, and links to the release that introduced the error.
|
||||||
|
|
||||||
|
GlitchTip complements Loki: use GlitchTip when you need **grouped, de-duplicated errors with stack traces and release attribution**; use Loki when you need **raw log lines with full context** or want to search across all log levels.
|
||||||
|
|
||||||
|
## Direct API access (debugging only)
|
||||||
|
|
||||||
|
Loki and Tempo bind no host ports. To reach them directly from your laptop, use an SSH tunnel through the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Loki API on localhost:3100 (then query via curl or logcli)
|
||||||
|
ssh -L 3100:172.20.0.x:3100 root@raddatz.cloud
|
||||||
|
# Replace 172.20.0.x with the obs-loki container IP:
|
||||||
|
# docker inspect obs-loki --format '{{.NetworkSettings.Networks.archiv-obs-net.IPAddress}}'
|
||||||
|
|
||||||
|
# Tempo API on localhost:3200 (then query via curl or tempo-cli)
|
||||||
|
ssh -L 3200:172.20.0.x:3200 root@raddatz.cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
In practice, Grafana Explore covers all common debugging workflows without needing direct API access.
|
||||||
|
|
||||||
|
## Signal summary
|
||||||
|
|
||||||
|
| Signal | Source | Transport | Storage | UI |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Application logs | Spring Boot stdout → Docker log driver | Promtail → Loki push API | Loki | Grafana Explore → Loki |
|
||||||
|
| Distributed traces | Spring Boot OTel agent | OTLP HTTP → Tempo:4318 | Tempo | Grafana Explore → Tempo |
|
||||||
|
| JVM + HTTP metrics | Spring Actuator `/actuator/prometheus` | Prometheus pull (15 s) | Prometheus | Grafana dashboards |
|
||||||
|
| Host metrics | node-exporter | Prometheus pull | Prometheus | Grafana → Node Exporter Full |
|
||||||
|
| Container metrics | cAdvisor | Prometheus pull | Prometheus | Grafana (via Prometheus datasource) |
|
||||||
|
| Application errors | Sentry SDK | HTTP POST → GlitchTip ingest | GlitchTip DB | GlitchTip UI |
|
||||||
68
docs/adr/008-fts-sql-pagination.md
Normal file
68
docs/adr/008-fts-sql-pagination.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-008: SQL-level pagination for full-text search via window-function CTE
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`DocumentRepository.findAllMatchingIdsByFts` (formerly `findRankedIdsByFts`) returns all matching document IDs for a FTS query. `DocumentService.searchDocuments` then paginates in memory on the RELEVANCE sort path.
|
||||||
|
|
||||||
|
A pre-production audit against 1,520 documents measured:
|
||||||
|
|
||||||
|
```
|
||||||
|
rows_per_call: 911 / call (query: "walter")
|
||||||
|
```
|
||||||
|
|
||||||
|
At current scale this is acceptable — 911 UUIDs ≈ 14 KB, ms-level DB time. At 100 K+ documents two failure modes emerge:
|
||||||
|
|
||||||
|
1. **Memory**: a broad query returns ~60 K UUIDs ≈ 1 MB per request, multiplied by concurrent users.
|
||||||
|
2. **Latency**: the `LATERAL` join does work proportional to match-set size; at 60 K matches the FTS step alone exceeds 100 ms per query.
|
||||||
|
|
||||||
|
Tracked as finding **F-31 (High)** in the pre-production architectural review.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Push pagination and rank ordering into SQL for the RELEVANCE sort path when no non-text filters are active (pure full-text search):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
`COUNT(*) OVER ()` returns the full match count alongside each page row in a single round-trip — no separate count query needed.
|
||||||
|
|
||||||
|
`rows_per_call` for the FTS query drops from match-set size (911) to page size (≤ 50).
|
||||||
|
|
||||||
|
When non-text filters (date range, sender, receiver, tags, status) are also active, the existing path is preserved: `findAllMatchingIdsByFts` returns all ranked IDs, which are passed as an `IN` clause to the JPA Specification, and `totalElements` comes from the JPA `Page.getTotalElements()`. This keeps the count accurate across the combined filter set.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
**1. Two-query approach (separate COUNT + paged SELECT)**
|
||||||
|
Correct, but doubles round-trips. The window function achieves the same result in one query.
|
||||||
|
|
||||||
|
**2. Capped result set with a user-visible warning**
|
||||||
|
Return at most N results (e.g. 500) and show "showing top 500 of many results". Simpler, but degrades UX for broad queries and doesn't reduce latency proportionally (still scans N rows).
|
||||||
|
|
||||||
|
**3. Full SQL rewrite combining FTS + JPA Specification filters**
|
||||||
|
Possible via a native query that embeds all filter predicates. Eliminates the in-memory SENDER/RECEIVER sort paths and the two-phase approach. High complexity, tight coupling to schema details, loses type-safe JPA Specification composition. Deferred to a future refactor if scale demands it.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **`rows_per_call` for pure-text FTS searches drops to ≤ page size** — the primary metric.
|
||||||
|
- **SENDER and RECEIVER sort paths stay in-memory** for combined text+filter queries. For pure-text queries with SENDER/RECEIVER sort, the current approach (fetch all matched IDs, build spec, load all matched entities, sort in-memory) still runs. This is acceptable while the archive stays under ~10 K documents.
|
||||||
|
- **RELEVANCE sort with text+filters still loads the full filtered entity set in-memory.** The filtered set is typically much smaller than the raw FTS match set, so the cost is bounded by filter selectivity, not total match count.
|
||||||
|
- **`findAllMatchingIdsByFts` is retained** for: (a) the bulk-edit "select all" fast path (`findIdsForFilter`), (b) the document density chart (`getDensity`), and (c) the SENDER/RECEIVER in-memory sort paths.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user