@@ -1574,3 +1574,218 @@ def test_search_total_pages_available(self, output_dir):
15741574
15751575 # Total pages should be embedded for JS to know how many pages to fetch
15761576 assert "totalPages" in index_html or "total_pages" in index_html
1577+
1578+
1579+ class TestAuthenticationErrorRecovery :
1580+ """Tests for 401 authentication error recovery in web command."""
1581+
1582+ def test_web_retries_after_401_on_fetch_sessions (
1583+ self , httpx_mock , monkeypatch , output_dir
1584+ ):
1585+ """Test that web command retries after 401 error when listing sessions."""
1586+ from click .testing import CliRunner
1587+ from claude_code_transcripts import cli
1588+
1589+ # Load sample session to mock API response
1590+ fixture_path = Path (__file__ ).parent / "sample_session.json"
1591+ with open (fixture_path ) as f :
1592+ session_data = json .load (f )
1593+
1594+ # First call to /sessions returns 401, second call succeeds
1595+ httpx_mock .add_response (
1596+ url = "https://api.anthropic.com/v1/sessions" ,
1597+ status_code = 401 ,
1598+ json = {
1599+ "type" : "error" ,
1600+ "error" : {
1601+ "type" : "authentication_error" ,
1602+ "message" : "Authentication failed" ,
1603+ },
1604+ },
1605+ )
1606+ httpx_mock .add_response (
1607+ url = "https://api.anthropic.com/v1/sessions" ,
1608+ json = {
1609+ "data" : [
1610+ {
1611+ "id" : "test-session-id" ,
1612+ "title" : "Test Session" ,
1613+ "created_at" : "2025-01-01T00:00:00Z" ,
1614+ }
1615+ ]
1616+ },
1617+ )
1618+ httpx_mock .add_response (
1619+ url = "https://api.anthropic.com/v1/session_ingress/session/test-session-id" ,
1620+ json = session_data ,
1621+ )
1622+
1623+ # Track if refresh_token was called
1624+ refresh_called = []
1625+
1626+ def mock_refresh_token ():
1627+ refresh_called .append (True )
1628+
1629+ monkeypatch .setattr ("claude_code_transcripts.refresh_token" , mock_refresh_token )
1630+
1631+ # Mock questionary.select to auto-select the first session
1632+ class MockSelect :
1633+ def __init__ (self , * args , ** kwargs ):
1634+ pass
1635+
1636+ def ask (self ):
1637+ return "test-session-id"
1638+
1639+ monkeypatch .setattr ("questionary.select" , MockSelect )
1640+
1641+ runner = CliRunner ()
1642+ # NOTE: No session_id provided, so it will call fetch_sessions first
1643+ result = runner .invoke (
1644+ cli ,
1645+ [
1646+ "web" ,
1647+ "--token" ,
1648+ "test-token" ,
1649+ "--org-uuid" ,
1650+ "test-org" ,
1651+ "-o" ,
1652+ str (output_dir ),
1653+ ],
1654+ )
1655+
1656+ assert result .exit_code == 0 , f"Command failed: { result .output } "
1657+ assert len (refresh_called ) == 1 , "refresh_token should be called exactly once"
1658+
1659+ def test_web_retries_after_401_on_fetch_session (
1660+ self , httpx_mock , monkeypatch , output_dir
1661+ ):
1662+ """Test that web command retries after 401 error when fetching session."""
1663+ from click .testing import CliRunner
1664+ from claude_code_transcripts import cli
1665+
1666+ # Load sample session to mock API response
1667+ fixture_path = Path (__file__ ).parent / "sample_session.json"
1668+ with open (fixture_path ) as f :
1669+ session_data = json .load (f )
1670+
1671+ # First call returns 401, second call succeeds
1672+ httpx_mock .add_response (
1673+ url = "https://api.anthropic.com/v1/session_ingress/session/test-session-id" ,
1674+ status_code = 401 ,
1675+ json = {
1676+ "type" : "error" ,
1677+ "error" : {
1678+ "type" : "authentication_error" ,
1679+ "message" : "Authentication failed" ,
1680+ },
1681+ },
1682+ )
1683+ httpx_mock .add_response (
1684+ url = "https://api.anthropic.com/v1/session_ingress/session/test-session-id" ,
1685+ json = session_data ,
1686+ )
1687+
1688+ # Track if refresh_token was called
1689+ refresh_called = []
1690+
1691+ def mock_refresh_token ():
1692+ refresh_called .append (True )
1693+
1694+ monkeypatch .setattr ("claude_code_transcripts.refresh_token" , mock_refresh_token )
1695+
1696+ runner = CliRunner ()
1697+ result = runner .invoke (
1698+ cli ,
1699+ [
1700+ "web" ,
1701+ "test-session-id" ,
1702+ "--token" ,
1703+ "test-token" ,
1704+ "--org-uuid" ,
1705+ "test-org" ,
1706+ "-o" ,
1707+ str (output_dir ),
1708+ ],
1709+ )
1710+
1711+ assert result .exit_code == 0 , f"Command failed: { result .output } "
1712+ assert len (refresh_called ) == 1 , "refresh_token should be called exactly once"
1713+
1714+ def test_web_only_retries_once_after_401 (self , httpx_mock , monkeypatch , output_dir ):
1715+ """Test that web command only retries once to prevent infinite loops."""
1716+ from click .testing import CliRunner
1717+ from claude_code_transcripts import cli
1718+
1719+ # Both calls return 401 - second 401 should not trigger another retry
1720+ httpx_mock .add_response (
1721+ url = "https://api.anthropic.com/v1/session_ingress/session/test-session-id" ,
1722+ status_code = 401 ,
1723+ json = {
1724+ "type" : "error" ,
1725+ "error" : {
1726+ "type" : "authentication_error" ,
1727+ "message" : "Authentication failed" ,
1728+ },
1729+ },
1730+ )
1731+ httpx_mock .add_response (
1732+ url = "https://api.anthropic.com/v1/session_ingress/session/test-session-id" ,
1733+ status_code = 401 ,
1734+ json = {
1735+ "type" : "error" ,
1736+ "error" : {
1737+ "type" : "authentication_error" ,
1738+ "message" : "Authentication failed" ,
1739+ },
1740+ },
1741+ )
1742+
1743+ # Track if refresh_token was called
1744+ refresh_called = []
1745+
1746+ def mock_refresh_token ():
1747+ refresh_called .append (True )
1748+
1749+ monkeypatch .setattr ("claude_code_transcripts.refresh_token" , mock_refresh_token )
1750+
1751+ runner = CliRunner ()
1752+ result = runner .invoke (
1753+ cli ,
1754+ [
1755+ "web" ,
1756+ "test-session-id" ,
1757+ "--token" ,
1758+ "test-token" ,
1759+ "--org-uuid" ,
1760+ "test-org" ,
1761+ "-o" ,
1762+ str (output_dir ),
1763+ ],
1764+ )
1765+
1766+ # Should fail after retry fails
1767+ assert result .exit_code != 0
1768+ assert "401" in result .output
1769+ assert len (refresh_called ) == 1 , "refresh_token should only be called once"
1770+
1771+ def test_refresh_token_runs_claude_command (self , monkeypatch ):
1772+ """Test that refresh_token runs claude -p prompt."""
1773+ from claude_code_transcripts import refresh_token
1774+ import subprocess
1775+
1776+ # Track the command that was run
1777+ commands_run = []
1778+
1779+ def mock_run (cmd , ** kwargs ):
1780+ commands_run .append (cmd )
1781+ return subprocess .CompletedProcess (
1782+ args = cmd , returncode = 0 , stdout = "" , stderr = ""
1783+ )
1784+
1785+ monkeypatch .setattr (subprocess , "run" , mock_run )
1786+
1787+ refresh_token ()
1788+
1789+ assert len (commands_run ) == 1
1790+ assert commands_run [0 ][0 ] == "claude"
1791+ assert "-p" in commands_run [0 ]
0 commit comments