🧪 Tester view
Untested behaviours, edge cases, test gap inventory · QA Engineer, SDET, Test Lead
⚡ Agent trace · 13 steps · $0.0410 · click to expand
search_claims(query='test coverage test suite testing', category='any') →
search_claims(query='error exception failure invalid', category='any') →
search_claims(query='edge case boundary limit empty null', category='any') →
get_file_claims(file_path='fhirclient/_utils.py') →
get_file_claims(file_path='fhirclient/server.py') →
get_file_claims(file_path='fhirclient/auth.py') →
read_source_file(file_path='fhirclient/_utils.py', start_line=1) →
read_source_file(file_path='fhirclient/server.py', start_line=1) →
read_source_file(file_path='fhirclient/_utils.py', start_line=62) →
read_source_file(file_path='fhirclient/server.py', start_line=62) →
read_source_file(file_path='fhirclient/server.py', start_line=123) →
read_source_file(file_path='fhirclient/auth.py', start_line=1) →
read_source_file(file_path='fhirclient/auth.py', start_line=62) →
read_source_file(file_path='fhirclient/auth.py', start_line=123) →
read_source_file(file_path='fhirclient/auth.py', start_line=184) →
read_source_file(file_path='fhirclient/auth.py', start_line=245) →
read_source_file(file_path='fhirclient/auth.py', start_line=306) →
read_source_file(file_path='fhirclient/auth.py', start_line=367) →
read_source_file(file_path='fhirclient/auth.py', start_line=428)
Test Intelligence Report
High-Risk Untested Behaviours
1. Pagination: _sanitize_next_link docstring vs. implementation drift (HIGH)
File: fhirclient/_utils.py:48-73
The docstring claims the function "validates hostname against the origin server," but the implementation only checks that netloc is non-empty. It does not verify that the hostname matches the original FHIR server. This means a malicious or misconfigured server could redirect pagination to an arbitrary domain, and the client would follow it. The existing tests (test_sanitize_next_link_invalid_scheme, test_sanitize_next_link_missing_scheme, test_sanitize_next_link_missing_netloc) only cover the ValueError cases — there is no test that verifies the hostname validation that the docstring promises.
Risk: SSRF / open redirect via pagination links. An attacker-controlled FHIR server could return a next link pointing to an internal service, and the client would fetch it.
2. FHIRServer.__init__ — base_uri length check is fragile (HIGH)
File: fhirclient/server.py:49-58
The constructor rejects base_uri if len(base_uri) <= 10. This is an arbitrary heuristic (comment says "A URI can't possibly be less than 11 chars"). A valid URI like http://a.b (12 chars) passes, but https://x.y (13 chars) also passes. However, a URI like file:///tmp (12 chars) would pass the length check but would be semantically invalid for a FHIR server. There is no test for URIs that pass the length check but are otherwise malformed (e.g., missing scheme, invalid characters, relative paths).
Risk: Silent failures — a server could be initialized with a garbage URI that passes the length check but fails later with confusing errors.
3. FHIRServer.get_capability — broad exception catch swallows errors (HIGH)
File: fhirclient/server.py:83-88
try:
security = conf.rest[0].security
except Exception:
logger.info("No REST security statement found...")
This catches all exceptions, including AttributeError, IndexError, TypeError, etc. If conf.rest is None, conf.rest[0] raises TypeError, which is silently caught. If conf.rest is an empty list, IndexError is caught. The code then proceeds to call FHIRAuth.from_capability_security(security, settings) with security = None. This is not tested — there is no test for a CapabilityStatement that has no rest array, or a rest array with no security element.
Risk: Silent degradation of security. If the server returns a malformed CapabilityStatement, the client silently falls back to no-auth mode (auth_type = "none"), which means subsequent requests are unauthenticated.
4. FHIRServer.authorize and reauthorize — dead code after guard (MEDIUM)
File: fhirclient/server.py:127-135
def authorize(self):
if self.auth is None:
raise Exception("Not ready to authorize, I do not have an auth instance")
return self.auth.authorize(self) if self.auth is not None else None
The if self.auth is not None else None after the guard is dead code — the guard already ensures self.auth is not None. This suggests a refactoring artifact. The reauthorize method has the same pattern. No test exercises the else branch (which is unreachable), but the pattern indicates confusion about the control flow.
Risk: Low (dead code), but indicates the authorization flow may have been refactored incompletely. Tests should verify that authorize() and reauthorize() actually raise when auth is None.
5. FHIROAuth2Auth.ready — token expiry race condition (MEDIUM)
File: fhirclient/auth.py:168-171
@property
def ready(self):
if self.expires_at and self.expires_at < datetime.now():
self.reset()
return True if self.access_token else False
The ready property has a side effect: it calls self.reset() if the token is expired. This is a property, not a method — callers may not expect mutation. Additionally, reset() only clears access_token, auth_state, and code_verifier, but does not clear refresh_token or expires_at. After expiry, ready returns False, but refresh_token is still set, so reauthorize() could still be called. There is no test that verifies the state after token expiry.
Risk: Stale refresh tokens being reused after access token expiry. The reauthorize() method checks if self.refresh_token is None and returns None if so, but after ready triggers reset(), the refresh token is still present, so reauthorization proceeds with an expired context.
Edge Case Inventory
| Edge Case | File:Line | Description | Tested? |
|---|---|---|---|
base_uri exactly 11 chars |
server.py:49 |
Passes length check but may be http://a.bc |
❌ No |
base_uri exactly 10 chars |
server.py:49 |
Fails length check with generic Exception | ❌ No |
base_uri missing trailing / |
server.py:50 |
Appends / automatically |
✅ Yes (indirectly) |
bundle.link is None |
_utils.py:39 |
Returns None from _get_next_link |
❌ No |
bundle.link is empty list [] |
_utils.py:39 |
Returns None (falsy check) |
❌ No |
Multiple links with relation == "next" |
_utils.py:42-44 |
Returns the first one found | ❌ No |
next_link URL with fragment # |
_utils.py:65-71 |
urlparse parses it, scheme/netloc checked |
❌ No |
next_link URL with auth user:pass@host |
_utils.py:65-71 |
netloc is non-empty, passes validation |
❌ No |
security.extension is None |
auth.py:44 |
Skips OAuth2 extension parsing | ❌ No |
security.extension is empty list [] |
auth.py:44 |
Skips OAuth2 extension parsing | ❌ No |
| OAuth2 extension with no sub-extensions | auth.py:50-62 |
Logs warning, continues | ❌ No |
conf.rest is None |
server.py:84 |
Caught by broad except Exception |
❌ No |
conf.rest is empty list [] |
server.py:84 |
IndexError caught silently |
❌ No |
conf.rest[0].security is None |
server.py:84 |
Passed to from_capability_security |
❌ No |
| Callback URL with no query string | auth.py:264 |
urlsplit(url)[3] is empty, parse_qsl returns [] |
❌ No |
| Callback URL with malformed query | auth.py:264-266 |
Caught by except Exception, re-raised |
❌ No |
access_token missing from token response |
auth.py:319-320 |
Raises Exception("No access token received") |
❌ No |
expires_in is non-integer string |
auth.py:324 |
int(ret_params["expires_in"]) raises ValueError |
❌ No |
refresh_token in both response and params |
auth.py:331 |
Response value takes precedence | ❌ No |
app_secret is empty string "" |
auth.py:314 |
Falsy, so no HTTP Basic auth sent | ❌ No |
server.desired_scope is None |
auth.py:231,359 |
Passed as None in params dict |
❌ No |
server.launch_token is None |
auth.py:235 |
is not None check skips launch param |
❌ No |
code_verifier already set |
auth.py:241-243 |
Skips generation, reuses existing | ❌ No |
state dict missing app_id key |
auth.py:142 |
Falls back to self.app_id (which may be None) |
❌ No |
state is None in from_state |
auth.py:141 |
assert state raises AssertionError |
❌ No |
Error Handling Gaps
1. FHIRServer.__init__ — generic Exception for invalid base_uri (HIGH)
File: fhirclient/server.py:55-58
if not self.base_uri or len(self.base_uri) <= 10:
raise Exception("FHIRServer must be initialized with `base_uri` or `state`...")
Raises a generic Exception instead of a specific type (e.g., ValueError). Callers cannot catch this specifically without catching all exceptions. The error message is also misleading — it says "or state containing the base-URI" but if state is provided without base_uri, the same error fires because from_state (called at line 54) does not set self.base_uri — it only sets self.auth.
2. _request_access_token — no error handling for JSON decode failure (HIGH)
File: fhirclient/auth.py:316
ret_params = server.post_as_form(self._token_uri, params, auth).json()
If the token endpoint returns a non-JSON response (e.g., HTML error page, 500 with text), .json() raises json.JSONDecodeError, which propagates uncaught. This would crash the authorization flow with a confusing error. There is no test for non-JSON token responses.
3. _request_access_token — expires_in parsing can crash (MEDIUM)
File: fhirclient/auth.py:323-325
if "expires_in" in ret_params:
expires_in = int(ret_params["expires_in"])
self.expires_at = datetime.now() + timedelta(seconds=expires_in)
If the server returns expires_in as a non-integer (e.g., "3600.5" or "3.6e3"), int() raises ValueError. If it returns a very large number, timedelta(seconds=...) could overflow. No try/except around this conversion.
4. handle_callback — state mismatch leaks internal state (MEDIUM)
File: fhirclient/auth.py:273-277
stt = args.get("state")
if stt is None or self.auth_state != stt:
raise Exception(f"Invalid state, will not use this code. Have: {stt}, want: {self.auth_state}")
The error message includes both the received state and the expected state. This leaks the auth_state (a UUID) into the exception, which could appear in logs. An attacker who can read logs could use this to craft a valid state for a CSRF attack.
5. from_state — assert state crashes on None (MEDIUM)
File: fhirclient/auth.py:141
def from_state(self, state):
assert state
self.app_id = state.get("app_id") or self.app_id
Using assert for input validation is problematic because assertions are disabled with python -O. If assertions are disabled and state is None, state.get(...) raises AttributeError. The FHIRAuth.__init__ calls from_state(state) only if state is not None, but FHIROAuth2Auth.from_state is called from FHIRServer.from_state (line 54 in server.py) which may pass a state dict that has been partially constructed.
6. FHIRServer.get_capability — silent failure when client is None (LOW)
File: fhirclient/server.py:90-100
settings = {
"aud": self.aud,
"app_id": self.client.app_id if self.client is not None else None,
...
}
If self.client is None, app_id, app_secret, redirect_uri, and jwt_token are all set to None. This is handled gracefully, but there is no test for a FHIRServer initialized without a client.
Suggested Test Scenarios
Scenario 1: Pagination open redirect (HIGH)
Given a FHIR server returns a Bundle with a next link pointing to ftp://malicious-server/steal-data
When _sanitize_next_link is called with that URL
Then it should raise ValueError (tested)
But also:
Given a FHIR server returns a Bundle with a next link pointing to http://evil.com/internal-api
When _sanitize_next_link is called with that URL
Then it should raise ValueError because the hostname does not match the origin server
Actual behavior: Returns the URL unchanged — this is a bug
File: fhirclient/_utils.py:48-73
Scenario 2: Malformed CapabilityStatement with no rest array (HIGH)
Given a FHIR server returns a CapabilityStatement where rest is None
When FHIRServer.get_capability() is called
Then the broad except Exception catches the TypeError from conf.rest[0]
And self.auth is set to a FHIRAuth instance with auth_type = "none"
And subsequent requests are made without authentication
Expected: Should raise a specific error or fall back to a safe default with a clear warning
File: fhirclient/server.py:83-88
Scenario 3: Token endpoint returns non-JSON response (HIGH)
Given the OAuth2 token endpoint returns HTTP 200 with Content-Type: text/html and body <html>error</html>
When _request_access_token calls server.post_as_form(...).json()
Then a json.JSONDecodeError is raised and propagates uncaught
Expected: Should catch the error and raise a descriptive Exception
File: fhirclient/auth.py:316
Scenario 4: Token expiry with refresh token still set (MEDIUM)
Given a FHIROAuth2Auth instance with access_token = "valid", expires_at in the past, and refresh_token = "rtoken"
When ready property is accessed
Then reset() is called, setting access_token to None
And ready returns False
But refresh_token is still "rtoken"
When reauthorize(server) is called
Then it proceeds to refresh the token using the stale refresh token
Expected: reauthorize() should also check ready or expires_at before attempting refresh
File: fhirclient/auth.py:168-171, 371-383
Scenario 5: expires_in with non-integer value (MEDIUM)
Given the token endpoint returns {"access_token": "abc", "expires_in": "3600.5"}
When _request_access_token processes the response
Then int("3600.5") raises ValueError
Expected: Should handle float values by converting to int or using float
File: fhirclient/auth.py:323-325
Scenario 6: Callback URL with no query parameters (MEDIUM)
Given the OAuth2 callback URL is https://app.example.com/callback (no ?)
When handle_callback(url, server) is called
Then urlparse.urlsplit(url)[3] returns ""
And parse_qsl("") returns []
And dict([]) returns {}
And args.get("state") returns None
And the method raises Exception("Invalid state...")
Expected: Should raise a more descriptive error about missing callback parameters
File: fhirclient/auth.py:252-287
Scenario 7: Server initialized with base_uri exactly 11 chars (LOW)
Given FHIRServer.__init__ is called with base_uri = "http://a.bc" (11 chars)
When the constructor runs
Then len(base_uri) > 10 is True
And self.base_uri is set to "http://a.bc/"
But this URI is not a valid FHIR server endpoint
Expected: Should validate that the URI is a well-formed absolute URL, not just check length
File: fhirclient/server.py:49-58