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