diff --git a/nixos/modules/tests/exercise-storage.py b/nixos/modules/tests/exercise-storage.py
new file mode 100755
index 0000000000000000000000000000000000000000..1d7197e6251a7570aafd7ac61e0564a22fbf67c9
--- /dev/null
+++ b/nixos/modules/tests/exercise-storage.py
@@ -0,0 +1,31 @@
+#!/usr/bin/env python2
+
+from sys import argv
+from os import urandom
+
+def main():
+    (clientDir,) = argv[1:]
+
+    someData = urandom(2 ** 16)
+    with mkstemp() as (fd, name):
+        write(fd, someData)
+
+        cap = get([
+            "tahoe", "-d", clientDir,
+            "put", name,
+        ])
+
+        dataReadBack = get([
+            "tahoe", "-d", clientDir,
+            "get", cap,
+        ])
+
+    assert someData == dataReadBack
+
+
+def get(argv):
+    return check_output(argv)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/nixos/modules/tests/get-passes.py b/nixos/modules/tests/get-passes.py
new file mode 100755
index 0000000000000000000000000000000000000000..f387f9bef5ab8bad50d9b7579930dfb8f48e0d44
--- /dev/null
+++ b/nixos/modules/tests/get-passes.py
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+
+from sys import argv
+from requests import post
+from json import dumps
+
+def main():
+    clientAPIRoot, issuerAPIRoot = argv[1:]
+
+    voucher = "0123456789"
+    zkapauthz = clientAPIRoot + "/storage-plugins/privatestorage-zkapauthorizer-v1"
+
+    # Simulate a payment for a voucher.
+    post(
+        issuerAPIRoot + "/v1/stripe/webhook",
+        dumps(charge_succeeded_json(voucher)),
+    )
+
+    # Tell the client to redeem the voucher.
+    post(
+        zkapauthz + "/voucher",
+        dumps({"voucher": voucher}),
+    )
+
+    # Poll the vouchers list for a while to see it get redeemed.
+    expected = {"number": voucher, "redeemed": True}
+    retry(
+        "find redeemed voucher",
+        lambda: expected == get(zkapauthz + "/voucher/" + voucher),
+    )
+
+
+def retry(description, f):
+    for i in range(60):
+        print("trying to {}...".format(description))
+        if f():
+            print("{} succeeded".format(description))
+            break
+        sleep(1.0)
+    raise ValueError("failed to {} after many tries".format(description))
+
+
+def charge_succeeded_json(voucher):
+    # This structure copy/pasted from Stripe webhook web interface.
+    base_payload = {
+        "id": "evt_1FKSX2DeTd13VRuuhPaUDA2f",
+        "object": "event",
+        "api_version": "2016-07-06",
+        "created": 1568910660,
+        "data": {
+            "object": {
+                "id": "ch_1FKSX2DeTd13VRuuG9BXbqji",
+                "object": "charge",
+                "amount": 999,
+                "amount_refunded": 0,
+                "application": None,
+                "application_fee": None,
+                "application_fee_amount": None,
+                "balance_transaction": "txn_1FKSX2DeTd13VRuuqO1CJZ1e",
+                "billing_details": {
+                    "address": {
+                        "city": None,
+                        "country": None,
+                        "line1": None,
+                        "line2": None,
+                        "postal_code": None,
+                        "state": None
+                    },
+                    "email": None,
+                    "name": None,
+                    "phone": None
+                },
+                "captured": True,
+                "created": 1568910660,
+                "currency": "usd",
+                "customer": None,
+                "description": None,
+                "destination": None,
+                "dispute": None,
+                "failure_code": None,
+                "failure_message": None,
+                "fraud_details": {
+                },
+                "invoice": None,
+                "livemode": False,
+                "metadata": {
+                    "Voucher": None,
+                },
+                "on_behalf_of": None,
+                "order": None,
+                "outcome": {
+                    "network_status": "approved_by_network",
+                    "reason": None,
+                    "risk_level": "normal",
+                    "risk_score": 44,
+                    "seller_message": "Payment complete.",
+                    "type": "authorized"
+                },
+                "paid": True,
+                "payment_intent": None,
+                "payment_method": "card_1FKSX2DeTd13VRuus5VEjmjG",
+                "payment_method_details": {
+                    "card": {
+                        "brand": "visa",
+                        "checks": {
+                            "address_line1_check": None,
+                            "address_postal_code_check": None,
+                            "cvc_check": None
+                        },
+                        "country": "US",
+                        "exp_month": 9,
+                        "exp_year": 2020,
+                        "fingerprint": "HTJeRR4MXhAAkctF",
+                        "funding": "credit",
+                        "last4": "4242",
+                        "three_d_secure": None,
+                        "wallet": None
+                    },
+                    "type": "card"
+                },
+                "receipt_email": None,
+                "receipt_number": None,
+                "receipt_url": "https://pay.stripe.com/receipts/acct_198xN4DeTd13VRuu/ch_1FKSX2DeTd13VRuuG9BXbqji/rcpt_Fq8oAItSiNmcm0beiie6lUYin920E7a",
+                "refunded": False,
+                "refunds": {
+                    "object": "list",
+                    "data": [
+                    ],
+                    "has_more": False,
+                    "total_count": 0,
+                    "url": "/v1/charges/ch_1FKSX2DeTd13VRuuG9BXbqji/refunds"
+                },
+                "review": None,
+                "shipping": None,
+                "source": {
+                    "id": "card_1FKSX2DeTd13VRuus5VEjmjG",
+                    "object": "card",
+                    "address_city": None,
+                    "address_country": None,
+                    "address_line1": None,
+                    "address_line1_check": None,
+                    "address_line2": None,
+                    "address_state": None,
+                    "address_zip": None,
+                    "address_zip_check": None,
+                    "brand": "Visa",
+                    "country": "US",
+                    "customer": None,
+                    "cvc_check": None,
+                    "dynamic_last4": None,
+                    "exp_month": 9,
+                    "exp_year": 2020,
+                    "fingerprint": "HTJeRR4MXhAAkctF",
+                    "funding": "credit",
+                    "last4": "4242",
+                    "metadata": {
+                    },
+                    "name": None,
+                    "tokenization_method": None
+                },
+                "source_transfer": None,
+                "statement_descriptor": None,
+                "statement_descriptor_suffix": None,
+                "status": "succeeded",
+                "transfer_data": None,
+                "transfer_group": None
+            }
+        },
+        "livemode": False,
+        "pending_webhooks": 2,
+        "request": "req_5ozjOIAcOkvVUK",
+        "type": "charge.succeeded"
+    }
+    # Indicate the voucher the payment references.
+    base_payload["data"]["object"]["metadata"]["Voucher"] = voucher
+    return base_payload
+
+
+if __name__ == '__main__':
+    main()
diff --git a/nixos/modules/tests/private-storage.nix b/nixos/modules/tests/private-storage.nix
index b2f2f1f0af63d47257b701bcca655b1d2d4d2289..8e319ced066d311151cc9a6a035597c544caef5a 100644
--- a/nixos/modules/tests/private-storage.nix
+++ b/nixos/modules/tests/private-storage.nix
@@ -1,9 +1,18 @@
 let
   pkgs = import <nixpkgs> { };
+
+  # Separate helper programs so we can write as little perl inside a string
+  # inside a nix expression as possible.
+  run-introducer = ./run-introducer.py;
+  run-client = ./run-client.py;
+  get-passes = ./get-passes.py;
+  exercise-storage = ./exercise-storage.py;
+
   # Here are the preconstructed secrets which we can assign to the introducer.
   # This is a lot easier than having the introducer generate them and then
   # discovering and configuring the other nodes with them.
   pemFile = ./node.pem;
+
   tubID = "rr7y46ixsg6qmck4jkkc7hke6xe4sv5f";
   swissnum = "2k6p3wrabat5jrj7otcih4cjdema4q3m";
   introducerPort = 35151;
@@ -29,8 +38,10 @@ import <nixpkgs/nixos/tests/make-test.nix> {
     client =
       { config, pkgs, ... }:
       { environment.systemPackages = [
+          pkgs.python2
           pkgs.tahoe-lafs
           pkgs.daemonize
+          (pkgs.python3.withPackages (ps: [ ps.requests ]))
         ];
       } // networkConfig;
 
@@ -62,35 +73,14 @@ import <nixpkgs/nixos/tests/make-test.nix> {
   testScript =
     ''
       # Start booting all the VMs in parallel to speed up operations down below.
-      startAll;
+      # startAll;
 
-      #
       # Set up a Tahoe-LAFS introducer.
-      #
-      my ($code, $version) = $introducer->execute("tahoe --version");
-      $introducer->log($version);
-
-      $introducer->succeed(
-          'tahoe create-introducer ' .
-          '--port tcp:${toString introducerPort} ' .
-          '--location tcp:introducer:${toString introducerPort} ' .
-          '/tmp/introducer'
-      );
       $introducer->copyFileFromHost(
           '${pemFile}',
-          '/tmp/introducer/private/node.pem'
-      );
-      $introducer->copyFileFromHost(
-          '${introducerFURLFile}',
-          '/tmp/introducer/private/introducer.furl'
+          '/tmp/node.pem'
       );
-      $introducer->succeed(
-          'daemonize ' .
-          '-e /tmp/stderr ' .
-          '-o /tmp/stdout ' .
-          '$(type -p tahoe) run /tmp/introducer'
-      );
-
+      $introducer->succeed('set -eo pipefail; ${run-introducer} /tmp/node.pem ${toString introducerPort} ${introducerFURL} | systemd-cat');
       eval {
         $introducer->waitForOpenPort(${toString introducerPort});
         # Signal success. :/
@@ -105,7 +95,7 @@ import <nixpkgs/nixos/tests/make-test.nix> {
       #
       # Get a Tahoe-LAFS storage server up.
       #
-      my ($code, $version) = $storage->execute("tahoe --version");
+      my ($code, $version) = $storage->execute('tahoe --version');
       $storage->log($version);
 
       # The systemd unit should reach the running state.
@@ -123,51 +113,13 @@ import <nixpkgs/nixos/tests/make-test.nix> {
       #
       # Storage appears to be working so try to get a client to speak with it.
       #
-      my ($code, $version) = $client->execute("tahoe --version");
-      $client->log($version);
-
-      # Create a Tahoe-LAFS client on it.
-      $client->succeed(
-          'tahoe create-client ' .
-          '--shares-needed 1 ' .
-          '--shares-happy 1 ' .
-          '--shares-total 1 ' .
-          '--introducer ${introducerFURL} /tmp/client'
-      );
-
-      # Launch it
-      $client->succeed(
-          'daemonize ' .
-          '-e /tmp/stderr ' .
-          '-o /tmp/stdout ' .
-          '$(type -p tahoe) run /tmp/client'
-      );
+      $client->succeed('${run-client} ${introducerFURL}');
       $client->waitForOpenPort(3456);
 
-      #
       # Get some ZKAPs from the issuer.
-      #
-
-      # Simulate a payment for a voucher.
-      $voucher = "0123456789";
-      $client->succeed("${simulate-payment} $voucher");
-
-      # Tell the client to redeem the voucher.
-      $client->succeed("${redeem-voucher} $voucher");
+      $client->succeed('${get-passes} http://127.0.0.1:3456 http://issuer');
 
       # The client should be prepped now.  Make it try to use some storage.
-      my ($code, $out) = $client->execute(
-          'tahoe -d /tmp/client ' .
-          'put /etc/issue'
-      );
-      ($code == 0) or do {
-          my ($code, $log) = $client->execute('cat /tmp/stdout /tmp/stderr');
-          $client->log($log);
-          die "put failed";
-      };
-      $client->succeed(
-          'tahoe -d /tmp/client ' .
-          "get $out"
-      );
+      $client->succeed('${exercise-storage}');
     '';
 }
diff --git a/nixos/modules/tests/run-client.py b/nixos/modules/tests/run-client.py
new file mode 100755
index 0000000000000000000000000000000000000000..cdf8ffeb62393c89fc86107bd7e1ffdc8fddfbb5
--- /dev/null
+++ b/nixos/modules/tests/run-client.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+
+from os import environ
+from sys import argv
+from shutil import which
+from subprocess import check_output
+
+def main():
+    (introducerFURL,) = argv[1:]
+
+    # PYTHONHOME set for Python 3 for this script breaks Python 2 used by
+    # Tahoe. :/ This is kind of a NixOS Python packaging bug.
+    del environ["PYTHONHOME"]
+
+    run(["tahoe", "--version"])
+    run([
+        "tahoe", "create-client",
+        "--shares-needed", "1",
+        "--shares-happy", "1",
+        "--shares-total", "1",
+        "--introducer", introducerFURL,
+        "/tmp/client",
+    ])
+
+    run([
+        "daemonize",
+        "-o", "/tmp/stdout",
+        "-e", "/tmp/stderr",
+        which("tahoe"), "run", "/tmp/client",
+    ])
+
+def run(argv):
+    print("{}: {}".format(argv, check_output(argv)))
+
+
+if __name__ == '__main__':
+    main()
diff --git a/nixos/modules/tests/run-introducer.py b/nixos/modules/tests/run-introducer.py
new file mode 100755
index 0000000000000000000000000000000000000000..40885590500b1c47bfac78c3526dc6c4eac4aeaf
--- /dev/null
+++ b/nixos/modules/tests/run-introducer.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+
+from sys import argv
+from os import environ, makedirs, rename
+from shutil import which
+from subprocess import check_output
+from socket import socket
+from time import sleep
+
+log = print
+
+def main():
+    pemFile, introducerPort, introducerFURL = argv[1:]
+
+    # PYTHONHOME set for Python 3 for this script breaks Python 2 used by
+    # Tahoe. :/ This is kind of a NixOS Python packaging bug.
+    del environ["PYTHONHOME"]
+
+    run(["tahoe", "--version"])
+    run([
+        "tahoe", "create-introducer",
+        "--port", "tcp:" + introducerPort,
+        "--location", "tcp:introducer:" + introducerPort,
+        "/tmp/introducer",
+    ])
+    rename(pemFile, "/tmp/introducer/private/node.pem")
+    with open("/tmp/introducer/private/introducer.furl", "w") as f:
+        f.write(introducerFURL)
+    run([
+        "daemonize",
+        "-o", "/tmp/stdout",
+        "-e", "/tmp/stderr",
+        which("tahoe"), "run", "/tmp/introducer",
+    ])
+
+    retry(
+        "waiting for open introducer port",
+        lambda: checkOpen(35151),
+    )
+
+
+def checkOpen(portNumber):
+    s = socket()
+    try:
+        s.connect(("127.0.0.1", portNumber))
+    except:
+        return False
+    else:
+        return True
+    finally:
+        s.close()
+
+
+def retry(description, f):
+    for i in range(60):
+        log("trying to {}...".format(description))
+        if f():
+            log("{} succeeded".format(description))
+            return
+        sleep(1.0)
+    raise ValueError("failed to {} after many tries".format(description))
+
+
+def run(argv):
+    log("Running {}".format(argv))
+    log("{}: {}".format(argv, check_output(argv)))
+
+
+if __name__ == '__main__':
+    main()