Skip to content

Commit 21c63aa

Browse files
jsbattigclaude
andcommitted
fix: Issue #716 - Jobs sorting, token whitespace, input type, decryption errors
Bug 1a: Change jobs sorting from created_at to started_at for accurate display Bug 1b: Extend timestamp display from 16 to 19 chars (include seconds) Bug 2a: Add .strip() to token before validation to handle whitespace Bug 2b: Change token input from password to text for easier entry Bug 3: Add graceful error handling for token decryption failures Bumps version to 8.5.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 765cd0b commit 21c63aa

8 files changed

Lines changed: 272 additions & 10 deletions

File tree

src/code_indexer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
HNSW graph indexing (O(log N) complexity).
77
"""
88

9-
__version__ = "8.5.0"
9+
__version__ = "8.5.1"
1010
__author__ = "Seba Battig"

src/code_indexer/server/services/ci_token_manager.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,17 @@ def get_token(self, platform: str) -> Optional[TokenData]:
306306
token_row = self._sqlite_backend.get_token(platform)
307307
if token_row is None:
308308
return None
309-
# Decrypt token
310-
decrypted_token = self._decrypt_token(token_row["encrypted_token"])
309+
# Decrypt token with graceful error handling (Issue #716 Bug 3)
310+
try:
311+
decrypted_token = self._decrypt_token(token_row["encrypted_token"])
312+
except Exception as e:
313+
logger.warning(
314+
f"Failed to decrypt {platform} token, treating as unconfigured: {e}",
315+
extra={"correlation_id": get_correlation_id()},
316+
)
317+
# Delete the corrupted token from storage
318+
self._sqlite_backend.delete_token(platform)
319+
return None
311320
return TokenData(
312321
platform=platform,
313322
token=decrypted_token,
@@ -322,8 +331,18 @@ def get_token(self, platform: str) -> Optional[TokenData]:
322331

323332
token_data = tokens[platform]
324333

325-
# Decrypt token
326-
decrypted_token = self._decrypt_token(token_data["token"])
334+
# Decrypt token with graceful error handling (Issue #716 Bug 3)
335+
try:
336+
decrypted_token = self._decrypt_token(token_data["token"])
337+
except Exception as e:
338+
logger.warning(
339+
f"Failed to decrypt {platform} token, treating as unconfigured: {e}",
340+
extra={"correlation_id": get_correlation_id()},
341+
)
342+
# Delete the corrupted token from storage
343+
del tokens[platform]
344+
self._save_tokens(tokens)
345+
return None
327346

328347
return TokenData(
329348
platform=platform,

src/code_indexer/server/web/routes.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2508,8 +2508,8 @@ def _get_all_jobs(
25082508
or (j.get("user_alias") and search_lower in j["user_alias"].lower())
25092509
]
25102510

2511-
# Sort by created_at (newest first)
2512-
all_jobs.sort(key=lambda x: x.get("created_at") or "", reverse=True)
2511+
# Sort by started_at (most recently started first), fall back to created_at
2512+
all_jobs.sort(key=lambda x: x.get("started_at") or x.get("created_at") or "", reverse=True)
25132513

25142514
# Pagination
25152515
total_count = len(all_jobs)
@@ -4810,6 +4810,8 @@ async def save_api_key(
48104810
# Save token using CITokenManager - use same server_dir as config service
48114811
try:
48124812
token_manager = _get_token_manager()
4813+
# Strip whitespace from token before validation (Issue #716 Bug 2a)
4814+
token = token.strip()
48134815
token_manager.save_token(platform, token, base_url=api_url)
48144816

48154817
platform_name = "GitHub" if platform == "github" else "GitLab"

src/code_indexer/server/web/templates/partials/config_section.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,7 @@ <h3>GitHub Actions</h3>
701701
<div class="form-grid">
702702
<label for="github-token">
703703
GitHub Token
704-
<input type="password" id="github-token" name="token"
704+
<input type="text" id="github-token" name="token"
705705
placeholder="ghp_..."
706706
value="">
707707
<small>Personal Access Token (classic) or Fine-grained PAT with workflow permissions</small>
@@ -765,7 +765,7 @@ <h3 style="margin-top: 2rem;">GitLab CI</h3>
765765
<div class="form-grid">
766766
<label for="gitlab-token">
767767
GitLab Token
768-
<input type="password" id="gitlab-token" name="token"
768+
<input type="text" id="gitlab-token" name="token"
769769
placeholder="glpat-..."
770770
value="">
771771
<small>Personal Access Token or Project Access Token with api scope</small>

src/code_indexer/server/web/templates/partials/jobs_list.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ <h2>Jobs</h2>
6868
<span class="progress-na">-</span>
6969
{% endif %}
7070
</td>
71-
<td>{{ job.started_at[:16] if job.started_at else (job.created_at[:16] if job.created_at else 'N/A') }}</td>
71+
<td>{{ job.started_at[:19] if job.started_at else (job.created_at[:19] if job.created_at else 'N/A') }}</td>
7272
<td>
7373
{% if job.duration_seconds is defined and job.duration_seconds is not none %}
7474
{{ (job.duration_seconds // 60)|int }}m {{ (job.duration_seconds % 60)|int }}s
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Unit tests for Issue #716 Bug 2a: Token whitespace stripping.
3+
4+
Tokens with leading/trailing whitespace should be accepted after stripping.
5+
The stripping should happen at the routes layer (save_api_key function).
6+
7+
Tests are written FIRST following TDD methodology.
8+
"""
9+
10+
import pytest
11+
12+
13+
class TestTokenWhitespaceStripping:
14+
"""Tests for Bug 2a: Whitespace should be stripped before token validation."""
15+
16+
def test_save_token_strips_whitespace_before_validation(self, tmp_path):
17+
"""
18+
Bug 2a: Token with whitespace should be saved after stripping.
19+
20+
Given a valid token with leading/trailing whitespace
21+
When save_token is called (after strip in routes layer)
22+
Then the token should be saved successfully without whitespace
23+
24+
Note: The actual stripping happens in routes.py save_api_key().
25+
This test verifies the clean token works after stripping.
26+
"""
27+
from src.code_indexer.server.services.ci_token_manager import CITokenManager
28+
29+
server_dir = tmp_path / ".cidx-server"
30+
server_dir.mkdir()
31+
manager = CITokenManager(server_dir_path=str(server_dir))
32+
33+
# Token with whitespace - simulating what user might paste
34+
token_with_whitespace = " ghp_" + "a" * 36 + " "
35+
clean_token = token_with_whitespace.strip()
36+
37+
# After stripping (done in routes.py), save should work
38+
manager.save_token("github", clean_token)
39+
40+
# Verify token was saved correctly
41+
token_data = manager.get_token("github")
42+
assert token_data is not None
43+
assert token_data.token == clean_token
44+
assert token_data.token == "ghp_" + "a" * 36
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
Unit tests for Issue #716 Bug 3: Token decryption failure handling.
3+
4+
When token decryption fails (e.g., token encrypted on different machine),
5+
get_token() should return None gracefully instead of crashing the config page.
6+
7+
Tests are written FIRST following TDD methodology.
8+
"""
9+
10+
import json
11+
import pytest
12+
13+
14+
class TestTokenDecryptionFailureHandling:
15+
"""Tests for Bug 3: get_token should handle decryption failures gracefully."""
16+
17+
def test_get_token_returns_none_on_decryption_failure(self, tmp_path):
18+
"""
19+
Bug 3: get_token should return None when decryption fails.
20+
21+
Given a token that cannot be decrypted (corrupted data)
22+
When get_token is called
23+
Then it should return None instead of crashing
24+
"""
25+
from src.code_indexer.server.services.ci_token_manager import CITokenManager
26+
27+
server_dir = tmp_path / ".cidx-server"
28+
server_dir.mkdir()
29+
manager = CITokenManager(server_dir_path=str(server_dir))
30+
31+
# Save a valid token
32+
valid_token = "ghp_" + "a" * 36
33+
manager.save_token("github", valid_token)
34+
35+
# Corrupt the token in the file
36+
token_file = server_dir / "ci_tokens.json"
37+
with open(token_file, "r") as f:
38+
data = json.load(f)
39+
data["github"]["token"] = "YWJjZGVmZ2hpamtsbW5vcA=="
40+
with open(token_file, "w") as f:
41+
json.dump(data, f)
42+
43+
# This should NOT raise an exception - should return None
44+
result = manager.get_token("github")
45+
assert result is None, "get_token should return None when decryption fails"
46+
47+
def test_get_token_returns_none_on_invalid_iv_size(self, tmp_path):
48+
"""
49+
Bug 3: get_token should handle 'Invalid IV size' error gracefully.
50+
51+
Given a token with corrupted IV (empty base64)
52+
When get_token is called
53+
Then it should return None (not raise ValueError)
54+
"""
55+
from src.code_indexer.server.services.ci_token_manager import CITokenManager
56+
57+
server_dir = tmp_path / ".cidx-server"
58+
server_dir.mkdir()
59+
manager = CITokenManager(server_dir_path=str(server_dir))
60+
61+
# Save a valid token
62+
valid_token = "glpat-" + "a" * 20
63+
manager.save_token("gitlab", valid_token)
64+
65+
# Corrupt the token to cause "Invalid IV size" error
66+
token_file = server_dir / "ci_tokens.json"
67+
with open(token_file, "r") as f:
68+
data = json.load(f)
69+
data["gitlab"]["token"] = ""
70+
with open(token_file, "w") as f:
71+
json.dump(data, f)
72+
73+
# This should NOT raise ValueError: Invalid IV size (0) for CBC
74+
result = manager.get_token("gitlab")
75+
assert result is None, "get_token should return None for corrupted token"
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""
2+
Unit tests for Issue #716 Bug 1a: Jobs sorting by started_at.
3+
4+
Jobs dashboard should sort by started_at (not created_at) to show most recently
5+
started jobs first. Falls back to created_at when started_at is not available.
6+
7+
Tests are written FIRST following TDD methodology.
8+
"""
9+
10+
import pytest
11+
from unittest.mock import MagicMock, patch
12+
from datetime import datetime
13+
14+
15+
class TestJobsSortingByStartedAt:
16+
"""Tests for Bug 1a: Jobs should be sorted by started_at."""
17+
18+
def test_jobs_sorted_by_started_at_when_available(self):
19+
"""
20+
Bug 1a: Jobs should be sorted by started_at when available.
21+
22+
Given jobs with different started_at times
23+
When _get_all_jobs is called
24+
Then jobs are sorted by started_at (most recently started first)
25+
"""
26+
from src.code_indexer.server.web.routes import _get_all_jobs
27+
28+
# Job1: created early, started early
29+
mock_job1 = MagicMock()
30+
mock_job1.job_id = "job1"
31+
mock_job1.operation_type = "index"
32+
mock_job1.status = MagicMock(value="running")
33+
mock_job1.progress = 50
34+
mock_job1.created_at = datetime(2026, 1, 12, 10, 0, 0)
35+
mock_job1.started_at = datetime(2026, 1, 12, 10, 5, 0)
36+
mock_job1.completed_at = None
37+
mock_job1.error = None
38+
mock_job1.username = "user1"
39+
mock_job1.result = None
40+
41+
# Job2: created earlier, but started later (should appear first)
42+
mock_job2 = MagicMock()
43+
mock_job2.job_id = "job2"
44+
mock_job2.operation_type = "index"
45+
mock_job2.status = MagicMock(value="running")
46+
mock_job2.progress = 75
47+
mock_job2.created_at = datetime(2026, 1, 12, 9, 0, 0)
48+
mock_job2.started_at = datetime(2026, 1, 12, 11, 0, 0)
49+
mock_job2.completed_at = None
50+
mock_job2.error = None
51+
mock_job2.username = "user2"
52+
mock_job2.result = None
53+
54+
mock_job_manager = MagicMock()
55+
mock_job_manager.jobs = {"job1": mock_job1, "job2": mock_job2}
56+
mock_job_manager._lock = MagicMock()
57+
mock_job_manager._lock.__enter__ = MagicMock(return_value=None)
58+
mock_job_manager._lock.__exit__ = MagicMock(return_value=None)
59+
60+
with patch(
61+
"src.code_indexer.server.web.routes._get_background_job_manager",
62+
return_value=mock_job_manager
63+
):
64+
jobs, total, pages = _get_all_jobs()
65+
66+
assert len(jobs) == 2
67+
# job2 started later (11:00), should be first
68+
assert jobs[0]["job_id"] == "job2"
69+
assert jobs[1]["job_id"] == "job1"
70+
71+
def test_jobs_fallback_to_created_at_when_no_started_at(self):
72+
"""
73+
Bug 1a: Jobs without started_at should use created_at for sorting.
74+
75+
Given jobs where some have no started_at
76+
When _get_all_jobs is called
77+
Then those jobs use created_at for sorting
78+
"""
79+
from src.code_indexer.server.web.routes import _get_all_jobs
80+
81+
# Job1: queued (no started_at), created at 10:00
82+
mock_job1 = MagicMock()
83+
mock_job1.job_id = "job1"
84+
mock_job1.operation_type = "index"
85+
mock_job1.status = MagicMock(value="queued")
86+
mock_job1.progress = 0
87+
mock_job1.created_at = datetime(2026, 1, 12, 10, 0, 0)
88+
mock_job1.started_at = None
89+
mock_job1.completed_at = None
90+
mock_job1.error = None
91+
mock_job1.username = "user1"
92+
mock_job1.result = None
93+
94+
# Job2: queued (no started_at), created at 11:00 (should be first)
95+
mock_job2 = MagicMock()
96+
mock_job2.job_id = "job2"
97+
mock_job2.operation_type = "index"
98+
mock_job2.status = MagicMock(value="queued")
99+
mock_job2.progress = 0
100+
mock_job2.created_at = datetime(2026, 1, 12, 11, 0, 0)
101+
mock_job2.started_at = None
102+
mock_job2.completed_at = None
103+
mock_job2.error = None
104+
mock_job2.username = "user2"
105+
mock_job2.result = None
106+
107+
mock_job_manager = MagicMock()
108+
mock_job_manager.jobs = {"job1": mock_job1, "job2": mock_job2}
109+
mock_job_manager._lock = MagicMock()
110+
mock_job_manager._lock.__enter__ = MagicMock(return_value=None)
111+
mock_job_manager._lock.__exit__ = MagicMock(return_value=None)
112+
113+
with patch(
114+
"src.code_indexer.server.web.routes._get_background_job_manager",
115+
return_value=mock_job_manager
116+
):
117+
jobs, total, pages = _get_all_jobs()
118+
119+
assert len(jobs) == 2
120+
# job2 created later (11:00), should be first
121+
assert jobs[0]["job_id"] == "job2"
122+
assert jobs[1]["job_id"] == "job1"

0 commit comments

Comments
 (0)