Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

tranquil-pds / test.nix
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}