Skip to content

Commit c347372

Browse files
authored
feat(auth): adopt sessions locally (#5)
1 parent 9a38dee commit c347372

6 files changed

Lines changed: 129 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ client.locks.release("build", lease)
3434

3535
`get_session()` reads immutable local state. It does not refresh or validate the token.
3636

37+
Copy a complete native session into another client's memory:
38+
39+
```python
40+
session = source.auth.get_session()
41+
if session is not None:
42+
fresh.auth.set_session(session)
43+
```
44+
45+
`set_session()` copies the session without making a request or persisting credentials. It raises
46+
`ValueError` when the session type or any credential field is incomplete.
47+
3748
Realtime is async. Channels wrap `centrifuge-python`; the underlying client and
3849
subscription objects are not part of the public API.
3950

‎features/contract/auth.feature‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,12 @@ Feature: SDK authentication contract
1616
Then the SDK operation succeeds
1717
And the current session belongs to the contract user
1818
And the current session exposes access and refresh tokens
19+
20+
@auth @SDK-AUTH-003
21+
Scenario: A client adopts a supplied session locally
22+
Given the confirmed contract user
23+
When the client signs in with the contract user's credentials
24+
And a fresh client adopts the current session
25+
Then the SDK operation succeeds
26+
And the current session belongs to the contract user
27+
And the current session exposes access and refresh tokens

‎features/steps/sdk_contract_steps.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
classify_error,
1212
)
1313

14+
from volcano_sdk import VolcanoClient
15+
1416

1517
def _world(context: Any) -> ContractWorld:
1618
return context.contract
@@ -38,6 +40,19 @@ def read_current_session(context: Any) -> None:
3840
world.record(world.client.auth.get_session)
3941

4042

43+
@when("a fresh client adopts the current session")
44+
def adopt_current_session(context: Any) -> None:
45+
world = _world(context)
46+
source = world.client.auth.get_session()
47+
assert source is not None
48+
target = VolcanoClient(
49+
api_url=world.fixture["api_url"],
50+
anon_key=world.fixture["anon_key"],
51+
)
52+
world.record(lambda: target.auth.set_session(source))
53+
world.client = target
54+
55+
4156
@then("the SDK operation succeeds")
4257
def operation_succeeds(context: Any) -> None:
4358
outcome = _world(context).last_outcome

‎src/volcano_sdk/auth.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@
77
from ._transport import Transport, invoke, response_payload
88
from .models import Session
99

10+
_INCOMPLETE_SESSION = "Expected a complete Session"
11+
12+
13+
def _is_non_empty_string(value: object) -> bool:
14+
return isinstance(value, str) and bool(value.strip())
15+
16+
17+
def _has_complete_values(session: Session) -> bool:
18+
return all(
19+
_is_non_empty_string(value)
20+
for value in (
21+
session.access_token,
22+
session.refresh_token,
23+
session.user_id,
24+
)
25+
)
26+
27+
28+
def _copy_complete_session(session: object) -> Session:
29+
if not isinstance(session, Session) or not _has_complete_values(session):
30+
raise ValueError(_INCOMPLETE_SESSION)
31+
return Session(
32+
access_token=session.access_token,
33+
refresh_token=session.refresh_token,
34+
user_id=session.user_id,
35+
)
36+
1037

1138
class AuthContext(Protocol):
1239
"""Client capabilities required by the authentication facade."""
@@ -34,6 +61,12 @@ def get_session(self) -> Session | None:
3461
"""Return the immutable locally held session without validating it."""
3562
return self._client.current_session
3663

64+
def set_session(self, session: Session) -> Session:
65+
"""Copy a complete session into local client state."""
66+
owned = _copy_complete_session(session)
67+
self._client._set_session(owned)
68+
return owned
69+
3770
def sign_in(self, *, email: str, password: str) -> Session:
3871
"""Sign in a user and store the returned session."""
3972
response = invoke(

‎tests/unit/test_contract_bindings.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
ROOT = Path(__file__).parents[2]
1717
FEATURE_SHA256 = {
18-
"auth.feature": "70289856dcca9854464ae92659363f3395b2fe79d92ebb8374973f29f5c1994d",
18+
"auth.feature": "4145af3f1120331d100d8548b2abfcb98c0aeef552fd2b3304dc7e074e526ce2",
1919
"database.feature": (
2020
"4685b29357a621068b25984ff0de29cd4c504eebe5cfb597f0b999e29878a668"
2121
),
@@ -60,6 +60,7 @@ def test_every_contract_phrase_is_bound_verbatim() -> None:
6060
}
6161
assert bound == {
6262
"a service-role client",
63+
"a fresh client adopts the current session",
6364
"an authenticated client",
6465
"exactly the fixture row is returned",
6566
"one client subscribes and the other publishes the contract message",

‎tests/unit/test_state.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from dataclasses import dataclass
44
from typing import Any
55

6+
import pytest
7+
68
from volcano_sdk import Session, VolcanoClient
79

810

@@ -132,3 +134,60 @@ def test_auth_facade_reads_established_immutable_session_without_transport() ->
132134
user_id="user-123",
133135
)
134136
assert transport.authorizations == calls_after_sign_in
137+
138+
139+
def test_auth_facade_adopts_an_owned_session_without_transport() -> None:
140+
transport = StateTransport()
141+
client = VolcanoClient(anon_key="anon", _transport=transport)
142+
supplied = Session(
143+
access_token="adopted-access",
144+
refresh_token="adopted-refresh",
145+
user_id="adopted-user",
146+
)
147+
148+
adopted = client.auth.set_session(supplied)
149+
150+
assert adopted == supplied
151+
assert adopted is not supplied
152+
assert client.auth.get_session() is adopted
153+
assert transport.authorizations == []
154+
155+
156+
def test_auth_facade_adoption_replaces_the_current_session() -> None:
157+
transport = StateTransport()
158+
client = VolcanoClient(anon_key="anon", _transport=transport)
159+
client.auth.sign_in(email="user@example.com", password="secret")
160+
replacement = Session(
161+
access_token="replacement-access",
162+
refresh_token="replacement-refresh",
163+
user_id="replacement-user",
164+
)
165+
calls_after_sign_in = list(transport.authorizations)
166+
167+
adopted = client.auth.set_session(replacement)
168+
169+
assert client.auth.get_session() is adopted
170+
assert adopted == replacement
171+
assert transport.authorizations == calls_after_sign_in
172+
173+
174+
@pytest.mark.parametrize(
175+
"invalid",
176+
[
177+
object(),
178+
Session(access_token=" ", refresh_token="refresh", user_id="user"),
179+
Session(access_token="access", refresh_token="\t", user_id="user"),
180+
Session(access_token="access", refresh_token="refresh", user_id="\n"),
181+
],
182+
)
183+
def test_auth_facade_rejects_incomplete_adoption_without_mutation(invalid: Any) -> None:
184+
transport = StateTransport()
185+
client = VolcanoClient(anon_key="anon", _transport=transport)
186+
previous = client.auth.sign_in(email="user@example.com", password="secret")
187+
calls_after_sign_in = list(transport.authorizations)
188+
189+
with pytest.raises(ValueError, match="complete Session"):
190+
client.auth.set_session(invalid)
191+
192+
assert client.auth.get_session() is previous
193+
assert transport.authorizations == calls_after_sign_in

0 commit comments

Comments
 (0)