Our Personal Data Server from scratch!
0
fork

Configure Feed

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

at main 258 lines 9.4 kB view raw
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}