# Patch 5 of 5 ??? New Test Module: `ar_tls_tests.erl` > **Context for this agent**: This is part of a patchset adding optional TLS > to the Arweave p2p network. Nodes gossip TLS public keys (SPKI) via a signed > `GET /peers/keys` endpoint. Peers that learn a SPKI connect over TLS with > SPKI pinning. Fully backwards-compatible ??? nodes without TLS are unaffected. > Ask anything: "why did they do X?", "would approach Y work?", "explain Z differently." ## What This Patch Does Adds `apps/arweave/test/ar_tls_tests.erl` ??? 15 EUnit tests. All run without a live node or network (ETS only, tmp files for generated certs). Uses `-ifdef(AR_TEST)` to access `spki_from_cert_file/1` which is not exported in production builds. ## Test Coverage | Test function | What it verifies | |---------------|-----------------| | `spki_from_cert_file_test_` | Extracts SPKI from a real openssl-generated P-256 cert; errors on missing file | | `local_tls_info_not_configured_test_` | Empty ETS ??? `not_configured` | | `local_tls_info_configured_test_` | ETS with `local_tls_key` entry ??? `{ok, SPKI}` | | `gun_tls_opts_pinning_test` | `{ok,SPKI}` ??? `verify_peer` + correct `verify_fun` + `server_name_indication=disable` | | `verify_peer_spki_bad_cert_test` | `{bad_cert, _}` event ??? `{valid, State}` (skip CA errors) | | `verify_peer_spki_extension_test` | `{extension, _}` event ??? `{unknown, State}` | | `verify_peer_spki_valid_test` | `valid` event ??? `{valid, State}` | | `peer_tls_info_test_` (4 subtests) | not_found, set+get, overwrite, bidirectional ETS mapping | | `tls_cert_file_generate_test_` | `init/0` with `generate` atom ??? creates cert+key files, `local_tls_info` ok | | `tls_peers_config_test_` | `init/0` with `tls_peers` list ??? `peer_tls_info` returns the pre-seeded SPKI | ## Norm-Check: Simple test vs generator test Comparing against `ar_http_util_tests.erl` (no setup) and `ar_config_tests.erl` (config record, setup/teardown): ```erlang %% Simple: plain function, no shared state needed gun_tls_opts_pinning_test() -> FakeSPKI = <<"some_spki_der_for_test">>, Opts = ar_tls:gun_tls_opts({ok, FakeSPKI}), ?assertEqual(verify_peer, proplists:get_value(verify, Opts)). %% Generator: _test_() suffix, {setup, Setup, Teardown, Tests} peer_tls_info_test_() -> {setup, fun setup_clean_ets/0, fun teardown_ets/1, [ ?_test(test_peer_not_found()), ?_test(test_peer_set_and_get()), ... ]}. ``` ## Norm-Check: `AR_TEST` macro, not `TEST` The project uses `-ifdef(AR_TEST)` to gate test-only exports. The compile command passes `-D AR_TEST`. Using `-ifdef(TEST)` (common in other Erlang projects) would silently omit the export and cause "undefined function" at test time. ## Norm-Check: `setup_clean_ets/0` pattern for named ETS tables Named ETS tables persist across test cases within the same VM. The helper creates the table if it doesn't exist, or clears it if it does ??? safe whether the table was created by a prior test or by the test framework itself: ```erlang setup_clean_ets() -> case ets:info(ar_tls) of undefined -> ets:new(ar_tls, [set, public, named_table, {read_concurrency, true}]); _ -> ets:delete_all_objects(ar_tls) end. ``` ## Norm-Check: Section numbering in comments Test sections are numbered (`%% 5. Pinning opts...`). After removing the TOFU test (was section 5) in Task 1, subsequent sections were renumbered: 6???5, 7???6. Keeping numbering contiguous is a stated preference in the original author instructions ("short, clear, consistent and parallel"). ## Project File Listings ``` apps/arweave/src/ (~130 .erl files, prefix ar_*) ar_cli_parser.erl ar_http.erl ar_http_iface_client.erl ar_http_iface_middleware.erl ar_http_iface_server.erl ar_peers.erl ar_sup.erl ar_tls.erl <- NEW ar_util.erl ar_wallet.erl apps/arweave/test/ (~50 _tests.erl files) ar_config_tests.erl ar_http_iface_tests.erl ar_http_util_tests.erl ar_semaphore_tests.erl ar_tls_tests.erl <- NEW ar_wallet_tests.erl apps/arweave_config/include/ arweave_config.hrl <- modified arweave_config_spec.hrl ``` ## The Patch ```diff %% NEW TEST MODULE: apps/arweave/test/ar_tls_tests.erl %% %% 15 EUnit tests. All run without a live node or network ??? only ETS and tmp files. %% Tests use -ifdef(AR_TEST) exports from ar_tls.erl (spki_from_cert_file/1). %% %% Coverage: %% 1. spki_from_cert_file/1 ??? extracts SPKI from a real openssl-generated cert %% 2. local_tls_info not_configured ??? ETS empty ??? not_configured %% 3. local_tls_info configured ??? ETS populated ??? {ok, SPKI} %% 4. gun_tls_opts pinning ??? {ok,SPKI} ??? verify_peer + verify_fun %% 5. verify_peer_spki/3 ??? bad_cert/extension/valid event handling %% 6. peer_tls_info round-trip ??? set/get/overwrite/bidirectional ETS mapping %% 7. tls_cert_file=generate ??? init/0 creates cert+key files, local_tls_info ok %% 8. tls_peers config pre-seeding ??? init/0 with tls_peers list ??? peer_tls_info ok %% %% Section numbering: 5 (TOFU test removed in Task 1), 6, 7... renumbered to 5, 6... --- /dev/null 2026-04-05 22:13:40.373703200 +0000 +++ b/apps/arweave/test/ar_tls_tests.erl 2026-04-07 01:08:06.026856089 +0000 @@ -0,0 +1,230 @@ +%%% @doc Unit tests for ar_tls. +%%% +%%% These tests exercise the TLS peer key gossip helpers, SPKI extraction, +%%% auto-cert generation, and the gossip document signing/verification +%%% round-trip without starting any network listeners or requiring a live +%%% Arweave node. + +-module(ar_tls_tests). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("public_key/include/public_key.hrl"). +-include_lib("arweave_config/include/arweave_config.hrl"). + +%% =================================================================== +%% 1. spki_from_cert_file/1 +%% =================================================================== + +spki_from_cert_file_test_() -> + {setup, + fun make_tmp_cert/0, + fun del_dir/1, + fun({TmpDir, _CertFile, _KeyFile}) -> + CertFile = filename:join(TmpDir, "test.crt"), + [ + ?_assertMatch({ok, SPKIDer} when is_binary(SPKIDer) andalso + byte_size(SPKIDer) > 0, ar_tls:spki_from_cert_file(CertFile)), + ?_assertMatch({error, _}, ar_tls:spki_from_cert_file("/nonexistent")) + ] + end}. + +%% =================================================================== +%% 3. local_tls_info/0 returns not_configured when no cert files +%% =================================================================== + +local_tls_info_not_configured_test_() -> + {setup, + fun setup_clean_ets/0, + fun teardown_ets/1, + [ + ?_assertEqual(not_configured, ar_tls:local_tls_info()) + ]}. + +%% =================================================================== +%% 4. local_tls_info/0 returns {ok, SPKIDer} when cert files present +%% =================================================================== + +local_tls_info_configured_test_() -> + {setup, + fun() -> + setup_clean_ets(), + FakeSPKI = <<"fake_spki_der_bytes">>, + ets:insert(ar_tls, {local_tls_key, FakeSPKI}), + FakeSPKI + end, + fun(_) -> teardown_ets(ok) end, + fun(FakeSPKI) -> + [ + ?_assertEqual({ok, FakeSPKI}, ar_tls:local_tls_info()) + ] + end}. + + +%% =================================================================== +%% 5. Pinning opts ??? gun_tls_opts({ok, SPKI}) returns verify_peer +%% =================================================================== + +gun_tls_opts_pinning_test() -> + FakeSPKI = <<"some_spki_der_for_test">>, + Opts = ar_tls:gun_tls_opts({ok, FakeSPKI}), + ?assertEqual(verify_peer, proplists:get_value(verify, Opts)), + {Fun, State} = proplists:get_value(verify_fun, Opts), + ?assert(is_function(Fun, 3)), + ?assertEqual(FakeSPKI, State), + ?assertEqual(disable, proplists:get_value(server_name_indication, Opts)). + +%% =================================================================== +%% 6. verify_peer_spki/3 accepts matching, rejects mismatched +%% =================================================================== + +verify_peer_spki_bad_cert_test() -> + ?assertMatch({valid, <<"state">>}, + ar_tls:verify_peer_spki(ignored, {bad_cert, unknown_ca}, <<"state">>)). + +verify_peer_spki_extension_test() -> + ?assertMatch({unknown, <<"state">>}, + ar_tls:verify_peer_spki(ignored, {extension, whatever}, <<"state">>)). + +verify_peer_spki_valid_test() -> + ?assertMatch({valid, <<"state">>}, + ar_tls:verify_peer_spki(ignored, valid, <<"state">>)). + +%% =================================================================== +%% Peer TLS info round-trip (ETS-backed) +%% =================================================================== + +peer_tls_info_test_() -> + {setup, + fun setup_clean_ets/0, + fun teardown_ets/1, + [ + ?_test(test_peer_not_found()), + ?_test(test_peer_set_and_get()), + ?_test(test_peer_overwrite()), + ?_test(test_bidirectional_mapping()) + ]}. + +test_peer_not_found() -> + ?assertEqual(not_found, ar_tls:peer_tls_info({1, 2, 3, 4, 1984})). + +test_peer_set_and_get() -> + Peer = {10, 0, 0, 1, 1984}, + SPKI = <<"test_spki_der">>, + ok = ar_tls:set_peer_tls_info(Peer, SPKI), + ?assertEqual({ok, SPKI}, ar_tls:peer_tls_info(Peer)). + +test_peer_overwrite() -> + Peer = {10, 0, 0, 3, 1984}, + ok = ar_tls:set_peer_tls_info(Peer, <<"old_spki">>), + ok = ar_tls:set_peer_tls_info(Peer, <<"new_spki">>), + ?assertEqual({ok, <<"new_spki">>}, ar_tls:peer_tls_info(Peer)). + +test_bidirectional_mapping() -> + Peer = {10, 0, 0, 4, 1984}, + SPKI = <<"bidir_test_spki">>, + ok = ar_tls:set_peer_tls_info(Peer, SPKI), + %% Forward: peer -> key + ?assertEqual({ok, SPKI}, ar_tls:peer_tls_info(Peer)), + %% Reverse: key -> peer + ?assertMatch([{{tls_key_peer, SPKI}, Peer}], + ets:lookup(ar_tls, {tls_key_peer, SPKI})). + +%% =================================================================== +%% tls_cert_file = generate triggers auto-cert generation via init/0 +%% =================================================================== + +tls_cert_file_generate_test_() -> + {setup, + fun() -> + setup_clean_ets(), + TmpDir = lists:flatten(io_lib:format("/tmp/ar_tls_gen_test_~p", [rand:uniform(1000000)])), + ok = filelib:ensure_dir(filename:join(TmpDir, "dummy")), + Config = #config{ data_dir = TmpDir, tls_cert_file = generate }, + application:set_env(arweave, config, Config), + TmpDir + end, + fun(TmpDir) -> + teardown_ets(ok), + os:cmd("rm -rf " ++ TmpDir) + end, + fun(TmpDir) -> + [ + ?_test(begin + ok = ar_tls:init(), + CertFile = filename:join([TmpDir, "tls", "arweave.crt"]), + KeyFile = filename:join([TmpDir, "tls", "arweave.key"]), + ?assert(filelib:is_regular(CertFile)), + ?assert(filelib:is_regular(KeyFile)), + ?assertMatch({ok, _SPKIDer}, ar_tls:local_tls_info()) + end) + ] + end}. + +%% =================================================================== +%% tls_peers config pre-seeding via init/0 +%% =================================================================== + +tls_peers_config_test_() -> + {setup, + fun() -> + setup_clean_ets(), + Peer = {192, 168, 1, 1, 1984}, + SPKI = <<1,2,3,4,5>>, + Config = #config{tls_peers = [{Peer, SPKI}]}, + application:set_env(arweave, config, Config), + {Peer, SPKI} + end, + fun(_) -> teardown_ets(ok) end, + fun({Peer, SPKI}) -> + [ + ?_test(begin + ok = ar_tls:init(), + ?assertEqual({ok, SPKI}, ar_tls:peer_tls_info(Peer)) + end) + ] + end}. + +%% =================================================================== +%% Helpers +%% =================================================================== + +make_tmp_cert() -> + Dir = lists:flatten(io_lib:format("/tmp/ar_tls_test_~p", [rand:uniform(1000000)])), + ok = file:make_dir(Dir), + CertFile = filename:join(Dir, "test.crt"), + KeyFile = filename:join(Dir, "test.key"), + %% Generate using openssl if available, otherwise skip + case os:find_executable("openssl") of + false -> + {Dir, CertFile, KeyFile}; + _ -> + Cmd = lists:flatten(io_lib:format( + "openssl req -x509 -newkey ec" + " -pkeyopt ec_paramgen_curve:P-256" + " -keyout ~s -out ~s" + " -days 3650 -noenc" + " -subj '/CN=arweave-test' 2>&1", + [KeyFile, CertFile] + )), + os:cmd(Cmd), + {Dir, CertFile, KeyFile} + end. + +del_dir({Dir, _, _}) -> + os:cmd("rm -rf " ++ Dir). + +setup_clean_ets() -> + case ets:info(ar_tls) of + undefined -> + ets:new(ar_tls, [set, public, named_table, {read_concurrency, true}]); + _ -> + ets:delete_all_objects(ar_tls) + end. + +teardown_ets(_) -> + case ets:info(ar_tls) of + undefined -> + ok; + _ -> + ets:delete_all_objects(ar_tls) + end. ```