1{ pkgs, lib, ... }:
2
3let
4
5 radicale_calendars = {
6 type = "caldav";
7 url = "http://localhost:5232/";
8 # Radicale needs username/password.
9 username = "alice";
10 password = "password";
11 };
12
13 radicale_contacts = {
14 type = "carddav";
15 url = "http://localhost:5232/";
16 # Radicale needs username/password.
17 username = "alice";
18 password = "password";
19 };
20
21 xandikos_calendars = {
22 type = "caldav";
23 url = "http://localhost:8080/user/calendars";
24 # Xandikos warns
25 # > No current-user-principal returned, re-using URL http://localhost:8080/user/calendars/
26 # but we do not need username/password.
27 };
28
29 xandikos_contacts = {
30 type = "carddav";
31 url = "http://localhost:8080/user/contacts";
32 };
33
34 local_calendars = {
35 type = "filesystem";
36 path = "~/calendars";
37 fileext = ".ics";
38 };
39
40 local_contacts = {
41 type = "filesystem";
42 path = "~/contacts";
43 fileext = ".vcf";
44 };
45
46 mkPairs = a: b: {
47 calendars = {
48 a = "${a}_calendars";
49 b = "${b}_calendars";
50 collections = [
51 "from a"
52 "from b"
53 ];
54 };
55 contacts = {
56 a = "${a}_contacts";
57 b = "${b}_contacts";
58 collections = [
59 "from a"
60 "from b"
61 ];
62 };
63 };
64
65 mkRadicaleProps =
66 tag:
67 pkgs.writeText "Radicale.props" (
68 builtins.toJSON {
69 inherit tag;
70 }
71 );
72
73 writeLines =
74 name: eol: lines:
75 pkgs.writeText name (lib.concatMapStrings (l: "${l}${eol}") lines);
76
77 prodid = "-//NixOS test//EN";
78 dtstamp = "20231129T194743Z";
79
80 writeICS =
81 {
82 uid,
83 summary,
84 dtstart,
85 dtend,
86 }:
87 writeLines "${uid}.ics" "\r\n" [
88 "BEGIN:VCALENDAR"
89 "VERSION:2.0"
90 "PRODID:${prodid}"
91 "BEGIN:VEVENT"
92 "UID:${uid}"
93 "SUMMARY:${summary}"
94 "DTSTART:${dtstart}"
95 "DTEND:${dtend}"
96 "DTSTAMP:${dtstamp}"
97 "END:VEVENT"
98 "END:VCALENDAR"
99 ];
100
101 foo_ics = writeICS {
102 uid = "foo";
103 summary = "Epochalypse";
104 dtstart = "19700101T000000Z";
105 dtend = "20380119T031407Z";
106 };
107
108 bar_ics = writeICS {
109 uid = "bar";
110 summary = "One Billion Seconds";
111 dtstart = "19700101T000000Z";
112 dtend = "20010909T014640Z";
113 };
114
115 writeVCF =
116 {
117 uid,
118 name,
119 displayName,
120 email,
121 }:
122 writeLines "${uid}.vcf" "\r\n" [
123 # One of the tools enforces this order of fields.
124 "BEGIN:VCARD"
125 "VERSION:4.0"
126 "UID:${uid}"
127 "EMAIL;TYPE=INTERNET:${email}"
128 "FN:${displayName}"
129 "N:${name}"
130 "END:VCARD"
131 ];
132
133 foo_vcf = writeVCF {
134 uid = "foo";
135 name = "Doe;John;;;";
136 displayName = "John Doe";
137 email = "john.doe@example.org";
138 };
139
140 bar_vcf = writeVCF {
141 uid = "bar";
142 name = "Doe;Jane;;;";
143 displayName = "Jane Doe";
144 email = "jane.doe@example.org";
145 };
146
147in
148{
149 name = "vdirsyncer";
150
151 meta.maintainers = with lib.maintainers; [ schnusch ];
152
153 nodes = {
154 machine = {
155 services.radicale = {
156 enable = true;
157 settings.auth.type = "none";
158 };
159
160 services.xandikos = {
161 enable = true;
162 extraOptions = [ "--autocreate" ];
163 };
164
165 services.vdirsyncer = {
166 enable = true;
167 jobs = {
168
169 alice = {
170 user = "alice";
171 group = "users";
172 config = {
173 statusPath = "/home/alice/.vdirsyncer";
174 storages = {
175 inherit
176 local_calendars
177 local_contacts
178 radicale_calendars
179 radicale_contacts
180 ;
181 };
182 pairs = mkPairs "local" "radicale";
183 };
184 forceDiscover = true;
185 };
186
187 bob = {
188 user = "bob";
189 group = "users";
190 config = {
191 statusPath = "/home/bob/.vdirsyncer";
192 storages = {
193 inherit
194 local_calendars
195 local_contacts
196 xandikos_calendars
197 xandikos_contacts
198 ;
199 };
200 pairs = mkPairs "local" "xandikos";
201 };
202 forceDiscover = true;
203 };
204
205 remote = {
206 config = {
207 storages = {
208 inherit
209 radicale_calendars
210 radicale_contacts
211 xandikos_calendars
212 xandikos_contacts
213 ;
214 };
215 pairs = mkPairs "radicale" "xandikos";
216 };
217 forceDiscover = true;
218 };
219
220 };
221 };
222
223 users.users = {
224 alice.isNormalUser = true;
225 bob.isNormalUser = true;
226 };
227 };
228 };
229
230 testScript = ''
231 def run_unit(name):
232 machine.systemctl(f"start {name}")
233 # The service is Type=oneshot without RemainAfterExit=yes. Once it
234 # is finished it is no longer active and wait_for_unit will fail.
235 # When that happens we check if it actually failed.
236 try:
237 machine.wait_for_unit(name)
238 except:
239 machine.fail(f"systemctl is-failed {name}")
240
241 start_all()
242
243 machine.wait_for_open_port(5232)
244 machine.wait_for_open_port(8080)
245 machine.wait_for_unit("multi-user.target")
246
247 with subtest("alice -> radicale"):
248 # vdirsyncer cannot create create collections on Radicale,
249 # see https://vdirsyncer.pimutils.org/en/stable/tutorials/radicale.html
250 machine.succeed("runuser -u radicale -- install -Dm 644 ${mkRadicaleProps "VCALENDAR"} /var/lib/radicale/collections/collection-root/alice/foocal/.Radicale.props")
251 machine.succeed("runuser -u radicale -- install -Dm 644 ${mkRadicaleProps "VADDRESSBOOK"} /var/lib/radicale/collections/collection-root/alice/foocard/.Radicale.props")
252
253 machine.succeed("runuser -u alice -- install -Dm 644 ${foo_ics} /home/alice/calendars/foocal/foo.ics")
254 machine.succeed("runuser -u alice -- install -Dm 644 ${foo_vcf} /home/alice/contacts/foocard/foo.vcf")
255 run_unit("vdirsyncer@alice.service")
256
257 # test statusPath
258 machine.succeed("test -d /home/alice/.vdirsyncer")
259 machine.fail("test -e /var/lib/private/vdirsyncer/alice")
260
261 with subtest("bob -> xandikos"):
262 # I suspect Radicale shares the namespace for calendars and
263 # contacts, but Xandikos separates them. We just use `barcal` and
264 # `barcard` with Xandikos as well to avoid conflicts.
265 machine.succeed("runuser -u bob -- install -Dm 644 ${bar_ics} /home/bob/calendars/barcal/bar.ics")
266 machine.succeed("runuser -u bob -- install -Dm 644 ${bar_vcf} /home/bob/contacts/barcard/bar.vcf")
267 run_unit("vdirsyncer@bob.service")
268
269 # test statusPath
270 machine.succeed("test -d /home/bob/.vdirsyncer")
271 machine.fail("test -e /var/lib/private/vdirsyncer/bob")
272
273 with subtest("radicale <-> xandikos"):
274 # vdirsyncer cannot create create collections on Radicale,
275 # see https://vdirsyncer.pimutils.org/en/stable/tutorials/radicale.html
276 machine.succeed("runuser -u radicale -- install -Dm 644 ${mkRadicaleProps "VCALENDAR"} /var/lib/radicale/collections/collection-root/alice/barcal/.Radicale.props")
277 machine.succeed("runuser -u radicale -- install -Dm 644 ${mkRadicaleProps "VADDRESSBOOK"} /var/lib/radicale/collections/collection-root/alice/barcard/.Radicale.props")
278
279 run_unit("vdirsyncer@remote.service")
280
281 # test statusPath
282 machine.succeed("test -d /var/lib/private/vdirsyncer/remote")
283
284 with subtest("radicale -> alice"):
285 run_unit("vdirsyncer@alice.service")
286
287 with subtest("xandikos -> bob"):
288 run_unit("vdirsyncer@bob.service")
289
290 with subtest("compare synced files"):
291 # iCalendar files get reordered
292 machine.succeed("diff -u --strip-trailing-cr <(sort /home/alice/calendars/foocal/foo.ics) <(sort /home/bob/calendars/foocal/foo.ics) >&2")
293 machine.succeed("diff -u --strip-trailing-cr <(sort /home/bob/calendars/barcal/bar.ics) <(sort /home/alice/calendars/barcal/bar.ics) >&2")
294
295 machine.succeed("diff -u --strip-trailing-cr /home/alice/contacts/foocard/foo.vcf /home/bob/contacts/foocard/foo.vcf >&2")
296 machine.succeed("diff -u --strip-trailing-cr /home/bob/contacts/barcard/bar.vcf /home/alice/contacts/barcard/bar.vcf >&2")
297 '';
298}