forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
1{
2 pkgs,
3 self,
4 ...
5}:
6pkgs.testers.nixosTest {
7 name = "tranquil-pds";
8
9 nodes.server = {
10 config,
11 pkgs,
12 ...
13 }: {
14 imports = [self.nixosModules.default];
15
16 services.tranquil-pds = {
17 enable = true;
18 database.createLocally = true;
19
20 nginx = {
21 enable = true;
22 enableACME = false;
23 };
24
25 settings = {
26 PDS_HOSTNAME = "pds.test";
27 SERVER_HOST = "0.0.0.0";
28
29 DISABLE_RATE_LIMITING = 1;
30 TRANQUIL_PDS_ALLOW_INSECURE_SECRETS = 1;
31
32 JWT_SECRET="test-jwt-secret-must-be-32-chars-long";
33 DPOP_SECRET="test-dpop-secret-must-be-32-chars-long";
34 MASTER_KEY="test-master-key-must-be-32-chars-long";
35 };
36 };
37 };
38
39 testScript = ''
40 import json
41
42 server.wait_for_unit("postgresql.service")
43 server.wait_for_unit("tranquil-pds.service")
44 server.wait_for_unit("nginx.service")
45 server.wait_for_open_port(3000)
46 server.wait_for_open_port(80)
47
48 def xrpc(method, endpoint, *, headers=None, data=None, raw_body=None, via="nginx"):
49 host_header = "-H 'Host: pds.test'" if via == "nginx" else ""
50 base = "http://localhost" if via == "nginx" else "http://localhost:3000"
51 url = f"{base}/xrpc/{endpoint}"
52
53 parts = ["curl", "-sf", "-X", method, host_header]
54 if headers:
55 parts.extend(f"-H '{k}: {v}'" for k, v in headers.items())
56 if data is not None:
57 parts.append("-H 'Content-Type: application/json'")
58 parts.append(f"-d '{json.dumps(data)}'")
59 if raw_body:
60 parts.append(f"--data-binary @{raw_body}")
61 parts.append(f"'{url}'")
62
63 return server.succeed(" ".join(parts))
64
65 def xrpc_json(method, endpoint, **kwargs):
66 return json.loads(xrpc(method, endpoint, **kwargs))
67
68 def xrpc_status(endpoint, *, headers=None, via="nginx"):
69 host_header = "-H 'Host: pds.test'" if via == "nginx" else ""
70 base = "http://localhost" if via == "nginx" else "http://localhost:3000"
71 url = f"{base}/xrpc/{endpoint}"
72
73 parts = ["curl", "-s", "-o", "/dev/null", "-w", "'%{http_code}'", host_header]
74 if headers:
75 parts.extend(f"-H '{k}: {v}'" for k, v in headers.items())
76 parts.append(f"'{url}'")
77
78 return server.succeed(" ".join(parts)).strip()
79
80 def http_status(path, *, host="pds.test", via="nginx"):
81 base = "http://localhost" if via == "nginx" else "http://localhost:3000"
82 return server.succeed(
83 f"curl -s -o /dev/null -w '%{{http_code}}' -H 'Host: {host}' '{base}{path}'"
84 ).strip()
85
86 def http_get(path, *, host="pds.test"):
87 return server.succeed(
88 f"curl -sf -H 'Host: {host}' 'http://localhost{path}'"
89 )
90
91 def http_header(path, header, *, host="pds.test"):
92 return server.succeed(
93 f"curl -sI -H 'Host: {host}' 'http://localhost{path}'"
94 f" | grep -i '^{header}:'"
95 ).strip()
96
97 # --- testing that stuff is up in general ---
98
99 with subtest("service is running"):
100 status = server.succeed("systemctl is-active tranquil-pds")
101 assert "active" in status
102
103 with subtest("data directories exist"):
104 server.succeed("test -d /var/lib/tranquil-pds/blobs")
105 server.succeed("test -d /var/lib/tranquil-pds/backups")
106
107 with subtest("postgres database created"):
108 server.succeed("sudo -u tranquil-pds psql -d tranquil-pds -c 'SELECT 1'")
109
110 with subtest("healthcheck via backend"):
111 xrpc("GET", "_health", via="backend")
112
113 with subtest("healthcheck via nginx"):
114 xrpc("GET", "_health")
115
116 with subtest("describeServer"):
117 desc = xrpc_json("GET", "com.atproto.server.describeServer")
118 assert "availableUserDomains" in desc
119 assert "did" in desc
120 assert desc.get("inviteCodeRequired") == False
121
122 with subtest("nginx serves frontend"):
123 result = server.succeed("curl -sf -H 'Host: pds.test' http://localhost/")
124 assert "<html" in result.lower() or "<!" in result
125
126 with subtest("well-known proxied"):
127 code = http_status("/.well-known/atproto-did")
128 assert code != "502" and code != "504", f"well-known proxy broken: {code}"
129
130 with subtest("health endpoint proxied"):
131 code = http_status("/health")
132 assert code != "404" and code != "502", f"/health not proxied: {code}"
133
134 with subtest("robots.txt proxied"):
135 code = http_status("/robots.txt")
136 assert code != "404" and code != "502", f"/robots.txt not proxied: {code}"
137
138 with subtest("metrics endpoint proxied"):
139 code = http_status("/metrics")
140 assert code != "502", f"/metrics not proxied: {code}"
141
142 with subtest("oauth path proxied"):
143 code = http_status("/oauth/.well-known/openid-configuration")
144 assert code != "502" and code != "504", f"oauth proxy broken: {code}"
145
146 with subtest("subdomain routing works"):
147 code = http_status("/xrpc/_health", host="alice.pds.test")
148 assert code == "200", f"subdomain routing failed: {code}"
149
150 with subtest("oauth-client-metadata.json served with host substitution"):
151 meta_raw = http_get("/oauth-client-metadata.json")
152 meta = json.loads(meta_raw)
153 assert "client_id" in meta, f"no client_id in oauth-client-metadata: {meta}"
154 assert "pds.test" in meta_raw, "host substitution did not apply"
155
156 with subtest("static assets location exists"):
157 code = http_status("/assets/nonexistent.js")
158 assert code == "404", f"expected 404 for missing asset, got {code}"
159
160 with subtest("spa fallback works"):
161 code = http_status("/app/some/deep/route")
162 assert code == "200", f"SPA fallback broken: {code}"
163
164 with subtest("firewall ports open"):
165 server.succeed("ss -tlnp | grep ':80 '")
166 server.succeed("ss -tlnp | grep ':3000 '")
167
168 # --- test little bit of an account lifecycle ---
169
170 with subtest("create account"):
171 account = xrpc_json("POST", "com.atproto.server.createAccount", data={
172 "handle": "alice",
173 "password": "NixOS-Test-Pass-99!",
174 "email": "alice@pds.test",
175 "didType": "web",
176 })
177 assert "accessJwt" in account, f"no accessJwt: {account}"
178 assert "did" in account, f"no did: {account}"
179 access_token = account["accessJwt"]
180 did = account["did"]
181 assert did.startswith("did:web:"), f"expected did:web, got {did}"
182
183 with subtest("mark account verified"):
184 server.succeed(
185 f"sudo -u tranquil-pds psql -d tranquil-pds "
186 f"-c \"UPDATE users SET email_verified = true WHERE did = '{did}'\""
187 )
188
189 auth = {"Authorization": f"Bearer {access_token}"}
190
191 with subtest("get session"):
192 session = xrpc_json("GET", "com.atproto.server.getSession", headers=auth)
193 assert session["did"] == did
194 assert session["handle"] == "alice.pds.test", f"unexpected handle: {session['handle']}"
195
196 with subtest("create record"):
197 created = xrpc_json("POST", "com.atproto.repo.createRecord", headers=auth, data={
198 "repo": did,
199 "collection": "app.bsky.feed.post",
200 "record": {
201 "$type": "app.bsky.feed.post",
202 "text": "hello from lewis silly nix integration test",
203 "createdAt": "2025-01-01T00:00:00.000Z",
204 },
205 })
206 assert "uri" in created, f"no uri: {created}"
207 assert "cid" in created, f"no cid: {created}"
208 record_uri = created["uri"]
209 record_cid = created["cid"]
210 rkey = record_uri.split("/")[-1]
211
212 with subtest("read record back"):
213 fetched = xrpc_json(
214 "GET",
215 f"com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={rkey}",
216 )
217 assert fetched["uri"] == record_uri
218 assert fetched["cid"] == record_cid
219 assert fetched["value"]["text"] == "hello from lewis silly nix integration test"
220
221 with subtest("upload blob"):
222 server.succeed("dd if=/dev/urandom bs=1024 count=4 of=/tmp/testblob.bin 2>/dev/null")
223 blob_resp = xrpc_json(
224 "POST",
225 "com.atproto.repo.uploadBlob",
226 headers={**auth, "Content-Type": "application/octet-stream"},
227 raw_body="/tmp/testblob.bin",
228 )
229 assert "blob" in blob_resp, f"no blob: {blob_resp}"
230 blob_ref = blob_resp["blob"]
231 assert blob_ref["size"] == 4096
232
233 with subtest("export repo as car"):
234 server.succeed(
235 f"curl -sf -H 'Host: pds.test' "
236 f"-o /tmp/repo.car "
237 f"'http://localhost/xrpc/com.atproto.sync.getRepo?did={did}'"
238 )
239 size = int(server.succeed("stat -c%s /tmp/repo.car").strip())
240 assert size > 0, "exported car is empty"
241
242 with subtest("delete record"):
243 xrpc_json("POST", "com.atproto.repo.deleteRecord", headers=auth, data={
244 "repo": did,
245 "collection": "app.bsky.feed.post",
246 "rkey": rkey,
247 })
248
249 with subtest("deleted record gone"):
250 code = xrpc_status(
251 f"com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={rkey}",
252 )
253 assert code != "200", f"expected non-200 for deleted record, got {code}"
254
255 with subtest("service still healthy after lifecycle"):
256 xrpc("GET", "_health")
257 '';
258}