1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
|
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Tests for reading and validating ~/.config/abusectl/config.toml."""
import os
import pathlib
import tempfile
import unittest
from unittest import mock
from abusectl import config, report
# Minimal, kept local rather than imported from test_report: these tests are
# about the shape config produces, and they must not start failing because a
# report fixture grew a field.
_MANIFEST = {
"format": 1,
"case_id": "2026-09-07-aaaa",
"iocs": [
{"id": "ioc-1", "type": "ipv4", "value": "203.0.113.42",
"origin": "received-chain", "confidence": "boundary-hop"},
],
"headers": [("From", '"Example Bank" <phish@sender.invalid>')],
"contacts": [
{"iocs": ["ioc-1"], "query": "203.0.113.42",
"abuse": ["abuse@host.invalid"], "source": "rdap"},
],
}
_DESTINATION = {"id": "d1", "target": "abuse@host.invalid", "iocs": ["ioc-1"]}
class TestConfig(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = pathlib.Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def _write(self, text: str) -> pathlib.Path:
path = self.root / "config.toml"
path.write_text(text, encoding="utf-8")
return path
def test_trusted_relays_and_cases_are_read(self):
path = self._write(
"[general]\n"
'cases = "~/cases"\n'
'trusted_relays = ["192.0.2.0/24"]\n'
)
loaded = config.load(path)
self.assertEqual(loaded.trusted_relays, ["192.0.2.0/24"])
self.assertEqual(loaded.cases, pathlib.Path.home() / "cases")
def test_a_missing_file_is_reported_as_not_configured(self):
with self.assertRaises(config.NotConfigured):
config.load(self.root / "absent.toml")
def test_an_empty_relay_list_is_not_configured(self):
# Present but empty is the same as absent: parse must refuse either
# way rather than guess, so they are one error.
path = self._write("[general]\ntrusted_relays = []\n")
with self.assertRaises(config.NotConfigured):
config.load(path)
def test_a_malformed_cidr_is_rejected_at_load(self):
# Reported against the file that holds the typo, not later against a
# message that did nothing wrong.
path = self._write('[general]\ntrusted_relays = ["not-a-network"]\n')
with self.assertRaises(ValueError):
config.load(path)
def test_the_cases_path_has_a_default(self):
path = self._write('[general]\ntrusted_relays = ["192.0.2.0/24"]\n')
self.assertEqual(config.load(path).cases, config.DEFAULT_CASES)
def test_malformed_toml_is_not_reported_as_not_configured(self):
# A syntax error is a broken file, which is a different problem from
# an absent one and must not be answered with "run abusectl init".
path = self._write("[general\ntrusted_relays = [")
with self.assertRaises(Exception) as caught:
config.load(path)
self.assertNotIsInstance(caught.exception, config.NotConfigured)
def test_an_empty_cases_value_falls_back_to_the_default(self):
# Empty is the same as absent, per this module's own rule: writing
# Path("") would put evidence in whatever directory the command
# happened to run from.
path = self._write(
'[general]\ntrusted_relays = ["192.0.2.0/24"]\ncases = ""\n'
)
self.assertEqual(config.load(path).cases, config.DEFAULT_CASES)
def test_a_whitespace_cases_value_falls_back_to_the_default(self):
path = self._write(
'[general]\ntrusted_relays = ["192.0.2.0/24"]\ncases = " "\n'
)
self.assertEqual(config.load(path).cases, config.DEFAULT_CASES)
def test_a_string_trusted_relays_is_rejected_clearly(self):
# Easy to write by hand without the brackets. Iterating the string
# validates single characters and reports an error naming nothing
# the user can find in their file.
path = self._write('[general]\ntrusted_relays = "192.0.2.0/24"\n')
with self.assertRaises(ValueError) as caught:
config.load(path)
self.assertIn("must be a list", str(caught.exception))
def test_a_relay_entry_that_is_not_a_string_is_rejected_clearly(self):
path = self._write("[general]\ntrusted_relays = [42]\n")
with self.assertRaises(ValueError):
config.load(path)
def test_the_reporter_identity_is_read(self):
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
'name = "A Reporter"\n'
'org = "Example Consulting"\n'
'email = "reporter@example.org"\n'
)
loaded = config.load(path)
self.assertEqual(loaded.reporter["name"], "A Reporter")
self.assertEqual(loaded.reporter["org"], "Example Consulting")
self.assertEqual(loaded.reporter["email"], "reporter@example.org")
def test_an_absent_reporter_section_is_an_empty_dict_not_a_crash(self):
path = self._write('[general]\ntrusted_relays = ["192.0.2.0/24"]\n')
self.assertEqual(config.load(path).reporter, {})
def test_an_empty_value_is_treated_as_absent(self):
# Same rule as cases and as every key init writes: skipped is ABSENT,
# never "". An empty org must not reach a report as a stray comma.
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
'name = "A Reporter"\n'
'org = ""\n'
'email = "reporter@example.org"\n'
)
reporter = config.load(path).reporter
self.assertNotIn("org", reporter)
self.assertEqual(reporter["name"], "A Reporter")
def test_a_whitespace_only_value_is_treated_as_absent(self):
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
'org = " "\n'
'email = "reporter@example.org"\n'
)
self.assertNotIn("org", config.load(path).reporter)
def test_a_reporter_value_is_stored_stripped(self):
# The name becomes a From display name. Leading and trailing space
# survives into the header verbatim, which is sloppy at best and
# affects folding at worst.
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
'name = " A Reporter "\n'
'email = " reporter@example.org "\n'
)
reporter = config.load(path).reporter
self.assertEqual(reporter["name"], "A Reporter")
self.assertEqual(reporter["email"], "reporter@example.org")
def test_a_reporter_value_that_is_not_a_string_is_rejected_clearly(self):
# Not dropped. Dropping reads as not-configured, and this is the one
# identity the tool discloses deliberately: a typo that silently
# removes the reply address must be reported against the file that
# holds it, the same way a non-string trusted_relays entry is.
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
"name = 42\n"
)
with self.assertRaises(ValueError) as caught:
config.load(path)
self.assertIn("must be a string", str(caught.exception))
self.assertIn("name", str(caught.exception))
def test_a_non_string_email_is_rejected_rather_than_dropped(self):
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
'email = ["reporter@example.org"]\n'
)
with self.assertRaises(ValueError):
config.load(path)
def test_a_string_reporter_section_is_rejected_clearly(self):
# reporter = "A Reporter" is valid TOML and would otherwise raise
# AttributeError naming nothing the user can find in their file. It
# has to precede [general]: a bare key written after a table header
# belongs to that table, not to the document.
path = self._write(
'reporter = "me"\n\n[general]\ntrusted_relays = ["192.0.2.0/24"]\n'
)
with self.assertRaises(ValueError) as caught:
config.load(path)
self.assertIn("must be a table", str(caught.exception))
def test_an_unknown_reporter_key_is_ignored(self):
# Ignored rather than refused: unlike a manifest format, an unknown
# key here loses nothing. Keeping only the three the spec names is
# what stops it reaching a report body.
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
'email = "reporter@example.org"\n'
'phone = "+1 555 0100"\n'
)
reporter = config.load(path).reporter
self.assertNotIn("phone", reporter)
self.assertEqual(reporter["email"], "reporter@example.org")
def test_a_configured_identity_builds_a_report_body(self):
# The round trip that only shows up much later otherwise: the dict
# config produces must be the shape report.build() consumes.
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
'name = "A Reporter"\n'
'org = "Example Consulting"\n'
'email = "reporter@example.org"\n'
)
body = report.build(_MANIFEST, _DESTINATION, config.load(path).reporter)
self.assertIn("A Reporter <reporter@example.org>", body)
self.assertIn(
"Reported by: A Reporter, Example Consulting "
"<reporter@example.org>", body)
def test_an_identity_with_only_an_email_still_builds_a_body(self):
# Every key is individually skippable per the spec, so config drops
# the skipped ones and build() must survive their absence rather
# than raising KeyError on a case that parsed fine.
path = self._write(
"[general]\n"
'trusted_relays = ["192.0.2.0/24"]\n'
"\n"
"[reporter]\n"
'email = "reporter@example.org"\n'
)
body = report.build(_MANIFEST, _DESTINATION, config.load(path).reporter)
self.assertIn("reporter@example.org", body)
def test_the_config_path_follows_xdg_config_home(self):
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/tmp/xdg-probe"}):
self.assertEqual(
config.path(),
pathlib.Path("/tmp/xdg-probe/abusectl/config.toml"),
)
if __name__ == "__main__":
unittest.main()
|