Current section
Files
Jump to
Current section
Files
lib/mix/tasks/rfc7208.testsuite.ex
defmodule Mix.Tasks.Rfc7208.Testsuite do
use Mix.Task
alias Mix
@moduledoc """
This mix task takes the
[rfc7208](https://www.rfc-editor.org/rfc/rfc7208.html) testsuite (a yaml
file) and for each section it creates:
- a test-file in the test dir, and
- a zone-file in the test/zones subdir.
Usage:
```
mix rfc7208.testsuite
mix test
mix test --only set:x, where x is in 0..14
mix test --only tst:x.y, where y refers to a specific test in section x
```
More information at [www.open-spf.org](http://www.open-spf.org):
- [open-spf testsuite](http://www.open-spf.org/Test_Suite)
- [rfc7208.2014-05 tests](http://www.open-spf.org/svn/project/test-suite/rfc7208-tests-2014-05-yml)
- [schema](http://www.open-spf.org/Test_Suite/Schema/)
### Sections of the rfc7208 testsuite include:
- 0 - Initial processing
- 1 - Record lookup
- 2 - Selecting records <- donot copy `SPF` record to a `TXT` record
- 3 - Record evaluation
- 4 - `ALL` mechanism syntax
- 5 - `PTR` mechanism syntax
- 6 - `A` mechanism syntax
- 7 - `INCLUDE` mechanism syntax
- 8 - `MX` mechanism syntax
- 9 - `EXISTS` mechanism syntax
- 10 - `IP4` mechanism syntax
- 11 - `IP6` mechanism syntax
- 12 - Semantics of EXP and other modifiers
- 13 - Macro expansion rules
- 14 - Processing limits
"""
# From the testsuite description
# 1) The zonedata map in the yml-file:
# - its keys are dns names
# - its values are lists of map's, each with a single entry
# - the key of the entry-map, is the RR-type
# - the value of the entry-map, is a string or list (eg for MX records)
# A value of a string is promoted to a list of 1 string
# 2) DNS errors:
# - *TIMEOUT*, when the last entry for a dns name in the zonedata is TIMEOUT,
# then all non-specified RR's should result in a timeout
# - *NONE*, when a TXT RR's value is none, the SPF record is NOT copied to the
# TXT RR: this allows record selection testing (see below)
# - RCODE: n, is not used at the moment
# For RFC 4408, the test suite was designed for use with SPF (type 99) and TXT
# implementations. In RFC 7208, use of type SPF has been removed.
# 3) The Selecting records:
# Test section 2 is the only one concerned with weeding out (incorrect)
# queries for type SPF of any kind or proper response to duplicate or
# conflicting records. Other sections rely on auto-magic duplication of SPF
# to TXT records (by test suite drivers) to test all implementation types with
# one specification.
@shortdoc "Creates the rfc7208 ExUnit testsuite from the rfc's yaml file"
@impl Mix.Task
def run(_args) do
all()
|> Enum.with_index()
|> Enum.map(§ion_to_test/1)
# ensure generate files are formatted, otherwise github actions will fail
Mix.shell().info("Formatting the test/rfc7208-*.exs files ...")
result = Mix.Tasks.Format.run(["test/rfc7208-*.exs"])
Mix.shell().info("- #{inspect(result)}")
Mix.shell().info("Done.")
end
defp section_to_test({{desc, dns, tests}, secnum}) do
desc = String.downcase(desc) |> String.replace(~r/\s/, "-")
section =
if secnum < 10,
do: "rfc7208-0#{secnum}-#{desc}",
else: "rfc7208-#{secnum}-#{desc}"
Mix.shell().info(section)
# create zonedata file
zonedir = "test/zones"
zonefile = Path.join(zonedir, "#{section}.zonedata")
dns = ["# Zonedata for #{section} of the Rfc7208 2014.5 testsuite\n"] ++ dns
with :ok <- File.mkdir_p(zonedir),
:ok <- File.write(zonefile, Enum.join(dns, "\n")) do
Mix.shell().info("- created #{zonefile}")
else
err -> Mix.shell().error("- failed to create zonefile for #{section}: #{inspect(err)}")
end
testcases = section_testcases(secnum, zonefile, tests)
# create section test file
testfile = """
defmodule Rfc7208.Section#{secnum}Test do
use ExUnit.Case
# Generated by mix rfc7208.testsuite
# Usage:
# % mix test
# % mix test --only set:#{secnum}
# % mix test --only tst:#{secnum}.y where y is in [0..#{length(tests) - 1}]
describe "#{section}" do
#{testcases}
end
end
"""
testfname = Path.join("test", "#{section}_test.exs")
with :ok <- File.write(testfname, testfile) do
Mix.shell().info("- created #{testfname}")
Mix.shell()
else
err -> Mix.shell().error("- failed to create #{testfname}: #{inspect(err)}")
end
end
defp section_testcases(secnum, zonefile, tests) do
for test <- tests do
{testname, sender, helo, ip, result, remark, explanation} = test
testnumber = testname |> String.split() |> List.first()
result = Enum.map(result, fn x -> inspect(x) end) |> Enum.join(", ")
"""
@tag set: "#{secnum}"
@tag tst: "#{testnumber}"
test "#{testname}" do
# #{remark}
ctx =
Spf.check("#{sender}",
helo: "#{helo}",
ip: "#{ip}",
dns: "#{zonefile}"
)
assert to_string(ctx.verdict) in [#{result}]
assert ctx.explanation == "#{explanation}"
end
"""
end
|> Enum.join("\n")
end
# Implementation
alias YamlElixir
@rfc7208_testsuite Path.join("priv", "rfc7208-tests-2014.05.yml")
# note: we omit CNAME and SOA here, since they're not used in the testsuite
@rrtypes ["A", "AAAA", "MX", "PTR", "SPF", "TXT"]
defp all() do
YamlElixir.read_all_from_file(@rfc7208_testsuite)
|> sections()
end
defp sections({:ok, docs}) do
docs
|> Enum.with_index()
|> Enum.map(&to_section/1)
|> List.flatten()
end
# Test Helpers
defp to_section({doc, nth}) do
desc = doc["description"]
dns = doc["zonedata"] |> to_dns_lines()
tests = doc["tests"] |> Enum.with_index()
tests =
for {test, mth} <- tests,
do: to_test(test, desc, nth, mth)
{desc, dns, tests}
end
defp to_test({name, test}, desc, nth, mth) do
spec = test["spec"] || ""
helo = test["helo"]
ip = test["host"]
mailfrom = test["mailfrom"]
result = test["result"] |> List.wrap() |> Enum.map(&String.downcase/1)
explanation = test["explanation"] || ""
# set DEFAULT explanation to ""
explanation = if explanation == "DEFAULT", do: "", else: explanation
info = Enum.join(["spec #{spec}", desc, name], " - ")
{"#{nth}.#{mth} #{name}", mailfrom, helo, ip, result, info, explanation}
end
# DNS helpers
defp to_dns_lines(zdata) do
for {domain, rdata} <- zdata do
for data <- rdata do
case data do
s when is_binary(s) -> {"OTHER", s}
%{"MX" => [pref, host]} -> {"MX", "#{pref} #{host}"}
m when is_map(m) -> Map.to_list(m) |> List.first()
end
end
|> cp_spf()
|> do_other()
|> Enum.map(fn {type, data} -> "#{domain} #{type} #{data}" end)
end
|> List.flatten()
end
defp cp_spf(rrs) do
# http://www.open-spf.org/Test_Suite/Schema/
# records of type SPF get special treatment:
# - If no records of type TXT are given for the same DNS name, then
# the SPF record is copied to a TXT record for all SPF RR's found.
#
# this reflects the recommendation of rfc4408, section 3.1.1 and allows the
# test suite to be used with implementations that choose any of the three
# options in section 4.4.
# in addition:
# - when the value of an SPF name is the string NONE, then that record
# is not added to the DNS data.
#
# as a result, TXT: NONE serves to suppress the auto copy of SPF records
# to TXT. This allows testing of record selection rules.
#
# ah, need to do cp_spf first, then w/ do_others, filter out any RR's that
# have value NONE first
copy = not Enum.any?(rrs, fn {type, _value} -> type == "TXT" end)
spfs =
Enum.filter(rrs, fn {k, v} -> k == "SPF" and v != "NONE" end)
|> Enum.map(fn {_, v} -> {"TXT", v} end)
none = Enum.filter(rrs, fn {_k, v} -> v == "NONE" end)
if copy and length(spfs) > 0 do
spfs ++ (rrs -- none)
else
rrs -- none
end
end
defp do_other(rrs) do
# {"OTHER", value} -> causes the non-specified RRs to be added with value
case List.keytake(rrs, "OTHER", 0) do
nil -> rrs
{{_, value}, rrs} -> add_others(rrs, value)
end
end
defp add_others(rrs, value) do
# add {type, value} for types not included in rrs
types = Enum.map(rrs, fn {type, _} -> String.upcase(type) end)
others = @rrtypes -- types
rrs ++ Enum.map(others, fn type -> {type, value} end)
end
end