tracebit-python 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/PKG-INFO +6 -7
  2. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/README.md +3 -6
  3. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/pyproject.toml +7 -1
  4. tracebit_python-0.1.1/src/tracebit/__init__.py +1 -0
  5. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/api.py +1 -1
  6. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/cli.py +51 -29
  7. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/PKG-INFO +6 -7
  8. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/SOURCES.txt +5 -1
  9. tracebit_python-0.1.1/src/tracebit_python.egg-info/requires.txt +4 -0
  10. tracebit_python-0.1.1/tests/test_api.py +89 -0
  11. tracebit_python-0.1.1/tests/test_aws.py +125 -0
  12. tracebit_python-0.1.1/tests/test_config.py +60 -0
  13. tracebit_python-0.1.1/tests/test_state.py +129 -0
  14. tracebit_python-0.1.0/src/tracebit/__init__.py +0 -1
  15. tracebit_python-0.1.0/src/tracebit_python.egg-info/requires.txt +0 -1
  16. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/LICENSE +0 -0
  17. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/setup.cfg +0 -0
  18. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/aws.py +0 -0
  19. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/config.py +0 -0
  20. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit/state.py +0 -0
  21. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/dependency_links.txt +0 -0
  22. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/entry_points.txt +0 -0
  23. {tracebit_python-0.1.0 → tracebit_python-0.1.1}/src/tracebit_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tracebit-python
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: CLI for managing Tracebit canary credentials on headless servers
5
5
  License: Apache-2.0
6
6
  Project-URL: Repository, https://github.com/SiteRelEnby/tracebit-python
@@ -21,6 +21,8 @@ Requires-Python: >=3.8
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: requests>=2.20
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
26
  Dynamic: license-file
25
27
 
26
28
  # tracebit-python
@@ -96,11 +98,11 @@ see an alert on the Tracebit dashboard within a few minutes.
96
98
 
97
99
  ### 5. Keep credentials fresh
98
100
 
99
- Canary credentials expire after ~24 hours. Set up a cron job to refresh them:
101
+ Canary credentials expire after ~12 hours. Set up a cron job to refresh them:
100
102
 
101
103
  ```bash
102
104
  # crontab -e
103
- 0 */12 * * * /path/to/tracebit refresh
105
+ 0 */6 * * * /path/to/tracebit refresh --hours 4
104
106
  ```
105
107
 
106
108
  ## Commands
@@ -132,10 +134,7 @@ from cron.
132
134
 
133
135
  | Option | Default | Description |
134
136
  |--------|---------|-------------|
135
- | `--hours` | `13` | Refresh credentials expiring within this many hours |
136
-
137
- With 24h credentials and a 12h cron, the default of 13 hours ensures every
138
- cron run refreshes credentials.
137
+ | `--hours` | `2` | Refresh credentials expiring within this many hours |
139
138
 
140
139
  ### `tracebit trigger aws`
141
140
 
@@ -71,11 +71,11 @@ see an alert on the Tracebit dashboard within a few minutes.
71
71
 
72
72
  ### 5. Keep credentials fresh
73
73
 
74
- Canary credentials expire after ~24 hours. Set up a cron job to refresh them:
74
+ Canary credentials expire after ~12 hours. Set up a cron job to refresh them:
75
75
 
76
76
  ```bash
77
77
  # crontab -e
78
- 0 */12 * * * /path/to/tracebit refresh
78
+ 0 */6 * * * /path/to/tracebit refresh --hours 4
79
79
  ```
80
80
 
81
81
  ## Commands
@@ -107,10 +107,7 @@ from cron.
107
107
 
108
108
  | Option | Default | Description |
109
109
  |--------|---------|-------------|
110
- | `--hours` | `13` | Refresh credentials expiring within this many hours |
111
-
112
- With 24h credentials and a 12h cron, the default of 13 hours ensures every
113
- cron run refreshes credentials.
110
+ | `--hours` | `2` | Refresh credentials expiring within this many hours |
114
111
 
115
112
  ### `tracebit trigger aws`
116
113
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tracebit-python"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "CLI for managing Tracebit canary credentials on headless servers"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -28,6 +28,9 @@ classifiers = [
28
28
  ]
29
29
  dependencies = ["requests>=2.20"]
30
30
 
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=7.0"]
33
+
31
34
  [project.urls]
32
35
  Repository = "https://github.com/SiteRelEnby/tracebit-python"
33
36
  Issues = "https://github.com/SiteRelEnby/tracebit-python/issues"
@@ -37,3 +40,6 @@ tracebit = "tracebit.cli:main"
37
40
 
38
41
  [tool.setuptools.packages.find]
39
42
  where = ["src"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -35,7 +35,7 @@ class TracebitClient:
35
35
  source_type="endpoint", labels=None):
36
36
  body = {
37
37
  "name": name,
38
- "types": types,
38
+ "credentialTypes": types,
39
39
  "source": source,
40
40
  "sourceType": source_type,
41
41
  }
@@ -18,6 +18,15 @@ from .state import (
18
18
  )
19
19
 
20
20
 
21
+ def _quiet(args):
22
+ return getattr(args, "quiet", False)
23
+
24
+
25
+ def _log(args, msg):
26
+ if not _quiet(args):
27
+ print(msg)
28
+
29
+
21
30
  def _get_client(args):
22
31
  token = getattr(args, "token", None) or load_token()
23
32
  if not token:
@@ -60,7 +69,7 @@ def cmd_configure(args):
60
69
  sys.exit(1)
61
70
 
62
71
  save_token(token)
63
- print("Token saved to ~/.config/tracebit/token")
72
+ _log(args, "Token saved to ~/.config/tracebit/token")
64
73
 
65
74
 
66
75
  def cmd_deploy_aws(args):
@@ -114,13 +123,13 @@ def cmd_deploy_aws(args):
114
123
  for old in existing:
115
124
  try:
116
125
  client.remove_credentials(old["name"], "aws")
117
- print(f"Expired previous canary '{old['name']}' on Tracebit.")
126
+ _log(args, f"Expired previous canary '{old['name']}' on Tracebit.")
118
127
  except TracebitError:
119
128
  pass
120
129
  remove_credential(old["name"], "aws")
121
130
 
122
131
  # issue credentials
123
- print(f"Issuing AWS canary credentials (name={name}, profile={profile})...")
132
+ _log(args, f"Issuing AWS canary credentials (name={name}, profile={profile})...")
124
133
  try:
125
134
  result = client.issue_credentials(
126
135
  name=name, types=["aws"], source="tracebit-python",
@@ -143,12 +152,12 @@ def cmd_deploy_aws(args):
143
152
  secret_access_key=aws["awsSecretAccessKey"],
144
153
  session_token=aws["awsSessionToken"],
145
154
  )
146
- print(f"Credentials written to ~/.aws/credentials [{profile}]")
155
+ _log(args, f"Credentials written to ~/.aws/credentials [{profile}]")
147
156
 
148
157
  # confirm deployment
149
158
  try:
150
159
  client.confirm_credentials(aws["awsConfirmationId"])
151
- print("Deployment confirmed with Tracebit.")
160
+ _log(args, "Deployment confirmed with Tracebit.")
152
161
  except TracebitError as e:
153
162
  print(f"Warning: Could not confirm deployment: {e}", file=sys.stderr)
154
163
 
@@ -170,7 +179,7 @@ def cmd_deploy_aws(args):
170
179
  "access_key_id": aws["awsAccessKeyId"],
171
180
  "expiration": aws["awsExpiration"],
172
181
  }, indent=2))
173
- else:
182
+ elif not _quiet(args):
174
183
  print(f"\nCanary deployed successfully!")
175
184
  print(f" Profile: {profile}")
176
185
  print(f" Region: {region}")
@@ -185,16 +194,23 @@ def cmd_refresh(args):
185
194
  expiring = get_expiring_credentials(hours=hours)
186
195
 
187
196
  if not expiring:
188
- print("No credentials need refreshing.")
197
+ _log(args, "No credentials need refreshing.")
189
198
  return
190
199
 
191
200
  failures = 0
192
201
  for cred in expiring:
193
202
  if cred["type"] != "aws":
194
- print(f"Skipping non-AWS credential: {cred['name']}")
203
+ _log(args, f"Skipping non-AWS credential: {cred['name']}")
195
204
  continue
196
205
 
197
- print(f"Refreshing AWS credential '{cred['name']}'...")
206
+ exp = cred.get("expiration", "")
207
+ try:
208
+ exp_dt = datetime.fromisoformat(exp.replace("Z", "+00:00"))
209
+ remaining = (exp_dt - datetime.now(timezone.utc)).total_seconds() / 3600
210
+ _log(args, f"Refreshing AWS credential '{cred['name']}' "
211
+ f"(expires in {remaining:.1f}h, threshold {hours}h)...")
212
+ except (ValueError, TypeError):
213
+ _log(args, f"Refreshing AWS credential '{cred['name']}'...")
198
214
  try:
199
215
  result = client.issue_credentials(
200
216
  name=cred["name"], types=["aws"], source="tracebit-python",
@@ -235,7 +251,7 @@ def cmd_refresh(args):
235
251
  "confirmation_id": aws["awsConfirmationId"],
236
252
  "labels": cred.get("labels", {}),
237
253
  })
238
- print(f" Refreshed. New expiration: {aws['awsExpiration']}")
254
+ _log(args, f" Refreshed. New expiration: {aws['awsExpiration']}")
239
255
 
240
256
  if failures:
241
257
  print(f"\n{failures} credential(s) failed to refresh.", file=sys.stderr)
@@ -262,7 +278,7 @@ def cmd_trigger_aws(args):
262
278
  cred = aws_creds[0]
263
279
 
264
280
  profile = cred["profile"]
265
- print(f"Triggering canary credential (profile={profile})...")
281
+ _log(args, f"Triggering canary credential (profile={profile})...")
266
282
 
267
283
  try:
268
284
  result = subprocess.run(
@@ -270,12 +286,12 @@ def cmd_trigger_aws(args):
270
286
  capture_output=True, text=True, timeout=10,
271
287
  )
272
288
  if result.returncode == 0 and "arn:aws:sts::" in result.stdout:
273
- print("Canary triggered successfully!")
274
- print(result.stdout.strip())
289
+ _log(args, "Canary triggered successfully!")
290
+ _log(args, result.stdout.strip())
275
291
  else:
276
- print("Trigger command ran but output was unexpected:")
292
+ _log(args, "Trigger command ran but output was unexpected:")
277
293
  if result.stdout:
278
- print(result.stdout.strip())
294
+ _log(args, result.stdout.strip())
279
295
  if result.stderr:
280
296
  print(result.stderr.strip(), file=sys.stderr)
281
297
  except FileNotFoundError:
@@ -293,7 +309,7 @@ def cmd_show(args):
293
309
  """Display deployed canary credentials."""
294
310
  creds = load_credentials()
295
311
  if not creds:
296
- print("No canary credentials deployed.")
312
+ _log(args, "No canary credentials deployed.")
297
313
  return
298
314
 
299
315
  if args.json_output:
@@ -314,15 +330,15 @@ def cmd_show(args):
314
330
  except ValueError:
315
331
  pass
316
332
 
317
- print(f" Name: {c['name']}")
318
- print(f" Type: {c['type']}")
333
+ _log(args, f" Name: {c['name']}")
334
+ _log(args, f" Type: {c['type']}")
319
335
  if c["type"] == "aws":
320
- print(f" Profile: {c.get('profile', 'n/a')}")
321
- print(f" Region: {c.get('region', 'n/a')}")
322
- print(f" Expires: {exp_str}{status}")
336
+ _log(args, f" Profile: {c.get('profile', 'n/a')}")
337
+ _log(args, f" Region: {c.get('region', 'n/a')}")
338
+ _log(args, f" Expires: {exp_str}{status}")
323
339
  if c.get("labels"):
324
- print(f" Labels: {c['labels']}")
325
- print()
340
+ _log(args, f" Labels: {c['labels']}")
341
+ _log(args, "")
326
342
 
327
343
 
328
344
  def cmd_remove(args):
@@ -336,33 +352,39 @@ def cmd_remove(args):
336
352
  matches = creds
337
353
 
338
354
  if not matches:
339
- print("No matching credentials found.")
355
+ _log(args, "No matching credentials found.")
340
356
  return
341
357
 
342
358
  for c in matches:
343
359
  # expire server-side
344
360
  try:
345
361
  client.remove_credentials(c["name"], c["type"])
346
- print(f"Expired '{c['name']}' ({c['type']}) on Tracebit.")
362
+ _log(args, f"Expired '{c['name']}' ({c['type']}) on Tracebit.")
347
363
  except TracebitError as e:
348
364
  print(f"Warning: Could not expire server-side: {e}", file=sys.stderr)
349
365
 
350
366
  if c["type"] == "aws":
351
367
  remove_aws_credentials(c.get("profile", ""))
352
- print(f"Removed AWS profile '{c.get('profile')}' from ~/.aws/")
368
+ _log(args, f"Removed AWS profile '{c.get('profile')}' from ~/.aws/")
353
369
  remove_credential(c["name"], c["type"])
354
- print(f"Removed credential '{c['name']}' ({c['type']}) from state.")
370
+ _log(args, f"Removed credential '{c['name']}' ({c['type']}) from state.")
355
371
 
356
372
 
357
373
  def main():
374
+ from . import __version__
375
+
358
376
  parser = argparse.ArgumentParser(
359
377
  prog="tracebit",
360
378
  description="Manage Tracebit canary credentials",
361
379
  )
380
+ parser.add_argument("--version", action="version",
381
+ version=f"%(prog)s {__version__}")
362
382
  parser.add_argument("--token", help="API token (overrides env/config)")
363
383
  parser.add_argument("--base-url", help="Override Tracebit API base URL")
364
384
  parser.add_argument("--json", dest="json_output", action="store_true",
365
385
  help="Output in JSON format where supported")
386
+ parser.add_argument("-q", "--quiet", action="store_true",
387
+ help="Suppress informational output (errors still print to stderr)")
366
388
 
367
389
  sub = parser.add_subparsers(dest="command")
368
390
 
@@ -387,8 +409,8 @@ def main():
387
409
 
388
410
  # refresh
389
411
  p_refresh = sub.add_parser("refresh", help="Refresh expiring credentials")
390
- p_refresh.add_argument("--hours", type=float, default=13,
391
- help="Refresh credentials expiring within this many hours (default: 13)")
412
+ p_refresh.add_argument("--hours", type=float, default=2,
413
+ help="Refresh credentials expiring within this many hours (default: 2)")
392
414
 
393
415
  # trigger
394
416
  p_trigger = sub.add_parser("trigger", help="Test-fire a canary credential")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tracebit-python
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: CLI for managing Tracebit canary credentials on headless servers
5
5
  License: Apache-2.0
6
6
  Project-URL: Repository, https://github.com/SiteRelEnby/tracebit-python
@@ -21,6 +21,8 @@ Requires-Python: >=3.8
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: requests>=2.20
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
26
  Dynamic: license-file
25
27
 
26
28
  # tracebit-python
@@ -96,11 +98,11 @@ see an alert on the Tracebit dashboard within a few minutes.
96
98
 
97
99
  ### 5. Keep credentials fresh
98
100
 
99
- Canary credentials expire after ~24 hours. Set up a cron job to refresh them:
101
+ Canary credentials expire after ~12 hours. Set up a cron job to refresh them:
100
102
 
101
103
  ```bash
102
104
  # crontab -e
103
- 0 */12 * * * /path/to/tracebit refresh
105
+ 0 */6 * * * /path/to/tracebit refresh --hours 4
104
106
  ```
105
107
 
106
108
  ## Commands
@@ -132,10 +134,7 @@ from cron.
132
134
 
133
135
  | Option | Default | Description |
134
136
  |--------|---------|-------------|
135
- | `--hours` | `13` | Refresh credentials expiring within this many hours |
136
-
137
- With 24h credentials and a 12h cron, the default of 13 hours ensures every
138
- cron run refreshes credentials.
137
+ | `--hours` | `2` | Refresh credentials expiring within this many hours |
139
138
 
140
139
  ### `tracebit trigger aws`
141
140
 
@@ -12,4 +12,8 @@ src/tracebit_python.egg-info/SOURCES.txt
12
12
  src/tracebit_python.egg-info/dependency_links.txt
13
13
  src/tracebit_python.egg-info/entry_points.txt
14
14
  src/tracebit_python.egg-info/requires.txt
15
- src/tracebit_python.egg-info/top_level.txt
15
+ src/tracebit_python.egg-info/top_level.txt
16
+ tests/test_api.py
17
+ tests/test_aws.py
18
+ tests/test_config.py
19
+ tests/test_state.py
@@ -0,0 +1,4 @@
1
+ requests>=2.20
2
+
3
+ [dev]
4
+ pytest>=7.0
@@ -0,0 +1,89 @@
1
+ from unittest.mock import MagicMock, patch
2
+ import pytest
3
+ import requests
4
+
5
+ from tracebit.api import TracebitClient, TracebitError
6
+
7
+
8
+ @pytest.fixture
9
+ def client():
10
+ return TracebitClient("test-token", base_url="https://example.com")
11
+
12
+
13
+ def mock_response(status_code=200, json_data=None, text=""):
14
+ resp = MagicMock()
15
+ resp.status_code = status_code
16
+ resp.json.return_value = json_data or {}
17
+ resp.text = text
18
+ resp.raise_for_status = MagicMock()
19
+ if status_code >= 400:
20
+ resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
21
+ return resp
22
+
23
+
24
+ def test_client_sets_auth_header(client):
25
+ assert client.session.headers["Authorization"] == "Bearer test-token"
26
+
27
+
28
+ def test_generate_metadata(client):
29
+ data = {"awsProfileName": "staging", "awsRegion": "us-east-1"}
30
+ with patch.object(client.session, "get", return_value=mock_response(json_data=data)) as m:
31
+ result = client.generate_metadata()
32
+ m.assert_called_once_with("https://example.com/api/v1/credentials/generate-metadata")
33
+ assert result["awsRegion"] == "us-east-1"
34
+
35
+
36
+ def test_issue_credentials_sends_correct_body(client):
37
+ resp_data = {"aws": {"awsAccessKeyId": "KEY", "awsConfirmationId": "abc"}}
38
+ with patch.object(client.session, "post", return_value=mock_response(json_data=resp_data)) as m:
39
+ client.issue_credentials(name="myserver", types=["aws"], labels={"env": "prod"})
40
+ body = m.call_args.kwargs["json"]
41
+ assert body["name"] == "myserver"
42
+ assert body["credentialTypes"] == ["aws"]
43
+ assert body["source"] == "tracebit-python"
44
+ assert {"name": "env", "value": "prod"} in body["labels"]
45
+
46
+
47
+ def test_issue_credentials_no_labels(client):
48
+ with patch.object(client.session, "post", return_value=mock_response()) as m:
49
+ client.issue_credentials(name="myserver", types=["aws"])
50
+ body = m.call_args.kwargs["json"]
51
+ assert "labels" not in body
52
+
53
+
54
+ def test_confirm_credentials(client):
55
+ with patch.object(client.session, "post", return_value=mock_response(status_code=204)) as m:
56
+ client.confirm_credentials("my-guid")
57
+ body = m.call_args.kwargs["json"]
58
+ assert body["id"] == "my-guid"
59
+
60
+
61
+ def test_confirm_credentials_404_raises(client):
62
+ with patch.object(client.session, "post", return_value=mock_response(status_code=404)):
63
+ with pytest.raises(TracebitError, match="not found"):
64
+ client.confirm_credentials("bad-guid")
65
+
66
+
67
+ def test_remove_credentials(client):
68
+ with patch.object(client.session, "post", return_value=mock_response(status_code=204)) as m:
69
+ client.remove_credentials("myserver", "aws")
70
+ body = m.call_args.kwargs["json"]
71
+ assert body["name"] == "myserver"
72
+ assert body["type"] == "aws"
73
+ m.assert_called_once_with(
74
+ "https://example.com/api/_internal/v1/cli/remove",
75
+ json=body,
76
+ )
77
+
78
+
79
+ def test_401_raises_tracebit_error(client):
80
+ with patch.object(client.session, "get", return_value=mock_response(status_code=401)):
81
+ with pytest.raises(TracebitError, match="Authentication failed"):
82
+ client.generate_metadata()
83
+
84
+
85
+ def test_400_raises_tracebit_error(client):
86
+ with patch.object(client.session, "post",
87
+ return_value=mock_response(status_code=400, text="bad request")):
88
+ with pytest.raises(TracebitError, match="bad request"):
89
+ client.issue_credentials("x", ["aws"])
@@ -0,0 +1,125 @@
1
+ import configparser
2
+ import pytest
3
+ from tracebit import aws as aws_mod
4
+
5
+
6
+ @pytest.fixture(autouse=True)
7
+ def tmp_aws(tmp_path, monkeypatch):
8
+ """Redirect ~/.aws/ to a temp directory for every test."""
9
+ aws_dir = tmp_path / ".aws"
10
+ monkeypatch.setattr(aws_mod, "AWS_DIR", aws_dir)
11
+ monkeypatch.setattr(aws_mod, "CREDENTIALS_FILE", aws_dir / "credentials")
12
+ monkeypatch.setattr(aws_mod, "CONFIG_FILE", aws_dir / "config")
13
+
14
+
15
+ def deploy(**kwargs):
16
+ defaults = dict(
17
+ profile="staging",
18
+ region="us-east-1",
19
+ access_key_id="AKIAIOSFODNN7EXAMPLE",
20
+ secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
21
+ session_token="AQoXnyc//token",
22
+ )
23
+ defaults.update(kwargs)
24
+ aws_mod.deploy_aws_credentials(**defaults)
25
+
26
+
27
+ def test_deploy_creates_credentials_file():
28
+ deploy()
29
+ assert aws_mod.CREDENTIALS_FILE.exists()
30
+
31
+
32
+ def test_deploy_writes_profile():
33
+ deploy(profile="myprofile", access_key_id="MYKEY")
34
+ creds = configparser.ConfigParser()
35
+ creds.read(str(aws_mod.CREDENTIALS_FILE))
36
+ assert creds.has_section("myprofile")
37
+ assert creds.get("myprofile", "aws_access_key_id") == "MYKEY"
38
+
39
+
40
+ def test_deploy_writes_session_token():
41
+ deploy(session_token="mysessiontoken")
42
+ creds = configparser.ConfigParser()
43
+ creds.read(str(aws_mod.CREDENTIALS_FILE))
44
+ assert creds.get("staging", "aws_session_token") == "mysessiontoken"
45
+
46
+
47
+ def test_deploy_writes_config():
48
+ deploy(profile="myprofile", region="eu-west-1")
49
+ cfg = configparser.ConfigParser()
50
+ cfg.read(str(aws_mod.CONFIG_FILE))
51
+ assert cfg.has_section("profile myprofile")
52
+ assert cfg.get("profile myprofile", "region") == "eu-west-1"
53
+
54
+
55
+ def test_deploy_file_permissions():
56
+ deploy()
57
+ assert oct(aws_mod.CREDENTIALS_FILE.stat().st_mode)[-3:] == "600"
58
+ assert oct(aws_mod.CONFIG_FILE.stat().st_mode)[-3:] == "600"
59
+
60
+
61
+ def test_deploy_dir_permissions():
62
+ deploy()
63
+ assert oct(aws_mod.AWS_DIR.stat().st_mode)[-3:] == "700"
64
+
65
+
66
+ def test_deploy_overwrites_existing_profile():
67
+ deploy(access_key_id="OLDKEY")
68
+ deploy(access_key_id="NEWKEY")
69
+ creds = configparser.ConfigParser()
70
+ creds.read(str(aws_mod.CREDENTIALS_FILE))
71
+ assert creds.get("staging", "aws_access_key_id") == "NEWKEY"
72
+ # should only be one section
73
+ assert len(creds.sections()) == 1
74
+
75
+
76
+ def test_deploy_preserves_other_profiles():
77
+ deploy(profile="staging")
78
+ deploy(profile="production")
79
+ creds = configparser.ConfigParser()
80
+ creds.read(str(aws_mod.CREDENTIALS_FILE))
81
+ assert creds.has_section("staging")
82
+ assert creds.has_section("production")
83
+
84
+
85
+ def test_profile_exists_true():
86
+ deploy(profile="staging")
87
+ assert aws_mod.profile_exists("staging") is True
88
+
89
+
90
+ def test_profile_exists_false():
91
+ assert aws_mod.profile_exists("nonexistent") is False
92
+
93
+
94
+ def test_remove_credentials():
95
+ deploy(profile="staging")
96
+ aws_mod.remove_aws_credentials("staging")
97
+ creds = configparser.ConfigParser()
98
+ creds.read(str(aws_mod.CREDENTIALS_FILE))
99
+ assert not creds.has_section("staging")
100
+
101
+
102
+ def test_remove_leaves_other_profiles():
103
+ deploy(profile="staging")
104
+ deploy(profile="production")
105
+ aws_mod.remove_aws_credentials("staging")
106
+ creds = configparser.ConfigParser()
107
+ creds.read(str(aws_mod.CREDENTIALS_FILE))
108
+ assert not creds.has_section("staging")
109
+ assert creds.has_section("production")
110
+
111
+
112
+ def test_remove_nonexistent_profile_is_safe():
113
+ deploy(profile="staging")
114
+ aws_mod.remove_aws_credentials("nonexistent") # should not raise
115
+
116
+
117
+ def test_get_aws_credentials():
118
+ deploy(profile="staging", access_key_id="MYKEY", secret_access_key="MYSECRET")
119
+ cred = aws_mod.get_aws_credentials("staging")
120
+ assert cred["aws_access_key_id"] == "MYKEY"
121
+ assert cred["aws_secret_access_key"] == "MYSECRET"
122
+
123
+
124
+ def test_get_aws_credentials_missing():
125
+ assert aws_mod.get_aws_credentials("missing") is None
@@ -0,0 +1,60 @@
1
+ import pytest
2
+ from tracebit import config as config_mod
3
+
4
+
5
+ @pytest.fixture(autouse=True)
6
+ def tmp_config(tmp_path, monkeypatch):
7
+ monkeypatch.setattr(config_mod, "CONFIG_DIR", tmp_path)
8
+ monkeypatch.setattr(config_mod, "TOKEN_FILE", tmp_path / "token")
9
+ monkeypatch.delenv("TRACEBIT_API_TOKEN", raising=False)
10
+ monkeypatch.delenv("TRACEBIT_URL", raising=False)
11
+
12
+
13
+ def test_load_token_from_env(monkeypatch):
14
+ monkeypatch.setenv("TRACEBIT_API_TOKEN", "my-token")
15
+ assert config_mod.load_token() == "my-token"
16
+
17
+
18
+ def test_load_token_strips_whitespace(monkeypatch):
19
+ monkeypatch.setenv("TRACEBIT_API_TOKEN", " my-token ")
20
+ assert config_mod.load_token() == "my-token"
21
+
22
+
23
+ def test_load_token_from_file(tmp_path):
24
+ token_file = tmp_path / "token"
25
+ token_file.write_text("file-token\n")
26
+ config_mod.TOKEN_FILE.__class__ # already patched via fixture
27
+ import tracebit.config as cm
28
+ cm.TOKEN_FILE = token_file
29
+ assert cm.load_token() == "file-token"
30
+
31
+
32
+ def test_load_token_env_takes_priority(monkeypatch, tmp_path):
33
+ token_file = tmp_path / "token"
34
+ token_file.write_text("file-token\n")
35
+ import tracebit.config as cm
36
+ cm.TOKEN_FILE = token_file
37
+ monkeypatch.setenv("TRACEBIT_API_TOKEN", "env-token")
38
+ assert cm.load_token() == "env-token"
39
+
40
+
41
+ def test_load_token_missing():
42
+ assert config_mod.load_token() is None
43
+
44
+
45
+ def test_save_token(tmp_path):
46
+ import tracebit.config as cm
47
+ cm.CONFIG_DIR = tmp_path
48
+ cm.TOKEN_FILE = tmp_path / "token"
49
+ cm.save_token("saved-token")
50
+ assert cm.TOKEN_FILE.read_text().strip() == "saved-token"
51
+ assert oct(cm.TOKEN_FILE.stat().st_mode)[-3:] == "600"
52
+
53
+
54
+ def test_get_base_url_default():
55
+ assert config_mod.get_base_url() == "https://community.tracebit.com"
56
+
57
+
58
+ def test_get_base_url_from_env(monkeypatch):
59
+ monkeypatch.setenv("TRACEBIT_URL", "https://my-tracebit.example.com/")
60
+ assert config_mod.get_base_url() == "https://my-tracebit.example.com"
@@ -0,0 +1,129 @@
1
+ import json
2
+ from datetime import datetime, timezone, timedelta
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ from tracebit.state import (
8
+ save_credential,
9
+ load_credentials,
10
+ remove_credential,
11
+ get_credential,
12
+ get_expiring_credentials,
13
+ )
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def tmp_state(tmp_path, monkeypatch):
18
+ """Redirect state file to a temp directory for every test."""
19
+ import tracebit.state as state_mod
20
+ monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path)
21
+ monkeypatch.setattr(state_mod, "STATE_FILE", tmp_path / "state.json")
22
+
23
+
24
+ def make_cred(**kwargs):
25
+ base = {
26
+ "name": "test-server",
27
+ "type": "aws",
28
+ "profile": "staging",
29
+ "region": "us-east-1",
30
+ "expiration": "2099-01-01T00:00:00Z",
31
+ "confirmation_id": "abc-123",
32
+ "labels": {},
33
+ }
34
+ base.update(kwargs)
35
+ return base
36
+
37
+
38
+ def test_save_and_load():
39
+ cred = make_cred()
40
+ save_credential(cred)
41
+ creds = load_credentials()
42
+ assert len(creds) == 1
43
+ assert creds[0]["name"] == "test-server"
44
+ assert creds[0]["profile"] == "staging"
45
+
46
+
47
+ def test_save_replaces_same_name_and_type():
48
+ save_credential(make_cred(profile="old"))
49
+ save_credential(make_cred(profile="new"))
50
+ creds = load_credentials()
51
+ assert len(creds) == 1
52
+ assert creds[0]["profile"] == "new"
53
+
54
+
55
+ def test_save_keeps_different_type():
56
+ save_credential(make_cred(type="aws"))
57
+ save_credential(make_cred(type="ssh"))
58
+ assert len(load_credentials()) == 2
59
+
60
+
61
+ def test_remove_by_name_and_type():
62
+ save_credential(make_cred())
63
+ remove_credential("test-server", "aws")
64
+ assert load_credentials() == []
65
+
66
+
67
+ def test_remove_by_name_only():
68
+ save_credential(make_cred(type="aws"))
69
+ save_credential(make_cred(type="ssh"))
70
+ remove_credential("test-server")
71
+ assert load_credentials() == []
72
+
73
+
74
+ def test_remove_leaves_others():
75
+ save_credential(make_cred(name="server-a"))
76
+ save_credential(make_cred(name="server-b"))
77
+ remove_credential("server-a", "aws")
78
+ creds = load_credentials()
79
+ assert len(creds) == 1
80
+ assert creds[0]["name"] == "server-b"
81
+
82
+
83
+ def test_get_credential():
84
+ save_credential(make_cred())
85
+ c = get_credential("test-server", "aws")
86
+ assert c is not None
87
+ assert c["profile"] == "staging"
88
+
89
+
90
+ def test_get_credential_not_found():
91
+ assert get_credential("missing", "aws") is None
92
+
93
+
94
+ def test_load_empty_state():
95
+ assert load_credentials() == []
96
+
97
+
98
+ def test_state_file_permissions(tmp_path, monkeypatch):
99
+ import tracebit.state as state_mod
100
+ state_file = tmp_path / "state.json"
101
+ monkeypatch.setattr(state_mod, "STATE_FILE", state_file)
102
+ save_credential(make_cred())
103
+ assert oct(state_file.stat().st_mode)[-3:] == "600"
104
+
105
+
106
+ def test_get_expiring_soon():
107
+ soon = (datetime.now(timezone.utc) + timedelta(hours=1)).strftime(
108
+ "%Y-%m-%dT%H:%M:%SZ"
109
+ )
110
+ save_credential(make_cred(expiration=soon))
111
+ expiring = get_expiring_credentials(hours=2)
112
+ assert len(expiring) == 1
113
+
114
+
115
+ def test_get_not_expiring():
116
+ far = (datetime.now(timezone.utc) + timedelta(hours=10)).strftime(
117
+ "%Y-%m-%dT%H:%M:%SZ"
118
+ )
119
+ save_credential(make_cred(expiration=far))
120
+ assert get_expiring_credentials(hours=2) == []
121
+
122
+
123
+ def test_get_already_expired():
124
+ past = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime(
125
+ "%Y-%m-%dT%H:%M:%SZ"
126
+ )
127
+ save_credential(make_cred(expiration=past))
128
+ expiring = get_expiring_credentials(hours=2)
129
+ assert len(expiring) == 1
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1 +0,0 @@
1
- requests>=2.20
File without changes