🧪 Tester view

Untested behaviours, edge cases, test gap inventory  ·  QA Engineer, SDET, Test Lead

Generated 2 months ago
⚡ 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_tokenexpires_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_stateassert 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


Complex Code Paths

1