Ltix.Test (Ltix v0.1.0)

Copy Markdown View Source

Helpers for testing applications that use Ltix.

Reduces LTI test setup to a single call. Instead of manually generating RSA keys, building JWKS payloads, creating registrations and deployments, starting storage adapters, and stubbing HTTP endpoints, call setup_platform!/1:

setup do
  %{platform: Ltix.Test.setup_platform!()}
end

The in-memory storage adapter state is scoped to the calling process via the process dictionary, so async tests are safe without any cleanup.

Configuration

If your app's controllers call Ltix.handle_login/3 or Ltix.handle_callback/3 without passing :storage_adapter in opts (relying on application config), add this to config/test.exs:

config :ltix, storage_adapter: Ltix.Test.StorageAdapter

This is safe for async: true tests — each test process gets its own in-memory storage via the process dictionary.

Controller tests (full OIDC flow)

Simulate a platform-initiated launch against your controller endpoints:

test "instructor launch renders dashboard", %{conn: conn, platform: platform} do
  conn = post(conn, ~p"/lti/login", Ltix.Test.login_params(platform))

  state = get_session(conn, :lti_state)
  nonce = Ltix.Test.extract_nonce(redirected_to(conn, 302))

  conn =
    conn
    |> recycle()
    |> Plug.Test.init_test_session(%{lti_state: state})
    |> post(~p"/lti/launch",
      Ltix.Test.launch_params(platform,
        nonce: nonce,
        state: state,
        roles: [:instructor],
        name: "Jane Doe"
      )
    )

  assert html_response(conn, 200) =~ "Dashboard"
end

Unit tests (direct context construction)

When testing business logic that receives a %LaunchContext{}, skip the OIDC flow entirely with build_launch_context/2:

test "instructors can manage grades", %{platform: platform} do
  context = Ltix.Test.build_launch_context(platform,
    roles: [:instructor],
    name: "Jane Smith"
  )

  assert MyApp.Permissions.can_manage_grades?(context)
end

See the Testing LTI Launches cookbook for more examples, including role customization and raw claim overrides.

Summary

Functions

Build a JWKS map from a list of public JWKs.

Build a %LaunchContext{} directly for unit testing.

Extract the nonce from a login redirect URI.

Generate an RSA key pair for testing.

Set up a simulated LTI platform in one call.

Return a complete, valid LTI claim set.

Verify a Deep Linking response JWT signed by the tool.

Functions

build_jwks(public_keys)

@spec build_jwks([JOSE.JWK.t()]) :: map()

Build a JWKS map from a list of public JWKs.

Returns %{"keys" => [...]}. Delegates to Ltix.JWK.to_jwks/1.

build_launch_context(platform, opts \\ [])

@spec build_launch_context(
  Ltix.Test.Platform.t(),
  keyword()
) :: Ltix.LaunchContext.t()

Build a %LaunchContext{} directly for unit testing.

Constructs the context without going through the OIDC flow. Accepts the same claim options as launch_params/2 (except :nonce and :state, which are not needed).

context = Ltix.Test.build_launch_context(platform,
  roles: [:instructor, :teaching_assistant],
  name: "Jane Smith",
  context: %{id: "course-1", title: "Elixir 101"}
)

For Deep Linking contexts, pass message_type: :deep_linking:

context = Ltix.Test.build_launch_context(platform,
  message_type: :deep_linking,
  deep_linking_settings: %{accept_types: ["ltiResourceLink"]}
)

callback_opts(platform)

@spec callback_opts(Ltix.Test.Platform.t()) :: keyword()

Options for Ltix.handle_callback/3 that work with setup_platform!/1.

Ltix.handle_callback(params, state, Ltix.Test.callback_opts(platform))

extract_nonce(redirect_uri)

@spec extract_nonce(String.t()) :: String.t()

Extract the nonce from a login redirect URI.

{:ok, result} = Ltix.handle_login(params, redirect_uri)
nonce = Ltix.Test.extract_nonce(result.redirect_uri)

generate_rsa_key_pair()

@spec generate_rsa_key_pair() :: {JOSE.JWK.t(), JOSE.JWK.t(), String.t()}

Generate an RSA key pair for testing.

Returns {private_jwk, public_jwk, kid}. Delegates to Ltix.JWK.generate_key_pair/0.

launch_params(platform, opts)

@spec launch_params(
  Ltix.Test.Platform.t(),
  keyword()
) :: map()

Build POST params for Ltix.handle_callback/3.

Signs a JWT with the platform's private key and returns %{"id_token" => jwt, "state" => state}.

Required options

  • :nonce — the nonce from the login redirect (use extract_nonce/1)
  • :state — the state from the login result

Optional

  • :message_type:deep_linking for Deep Linking requests (default: resource link)
  • :roles — list of role atoms (e.g., [:instructor]), %Role{} structs, or URI strings
  • :subject — user identifier (default: "user-12345")
  • :name, :email, :given_name, :family_name — user PII
  • :context — map with :id, :label, :title keys
  • :resource_link — map with :id, :title keys
  • :deep_linking_settings — map of DL settings (used when message_type: :deep_linking)
  • :claims — raw claim map merged last (for advanced overrides)

login_opts(platform)

@spec login_opts(Ltix.Test.Platform.t()) :: keyword()

Options for Ltix.handle_login/3 that work with setup_platform!/1.

Ltix.handle_login(params, redirect_uri, Ltix.Test.login_opts(platform))

login_params(platform, opts \\ [])

@spec login_params(
  Ltix.Test.Platform.t(),
  keyword()
) :: map()

Build POST params for Ltix.handle_login/3.

Options

  • :login_hint — login hint value (default: "user-hint")
  • :target_link_uri — launch URL (default: "https://tool.example.com/launch")

mint_id_token(claims, private_jwk, opts \\ [])

@spec mint_id_token(map(), JOSE.JWK.t(), keyword()) :: String.t()

Sign claims as a JWT.

Options

  • :kid — key ID for the JWT header
  • :alg — algorithm (default: "RS256")

setup_platform!(opts \\ [])

@spec setup_platform!(keyword()) :: Ltix.Test.Platform.t()

Set up a simulated LTI platform in one call.

Generates RSA keys, creates a registration and deployment, starts the in-memory storage adapter, and stubs the JWKS HTTP endpoint.

Options

  • :issuer — platform issuer URL (default: "https://platform.example.com")
  • :client_id — OAuth client ID (default: "tool-client-id")
  • :deployment_id — deployment identifier (default: "deployment-001")

valid_lti_claims(overrides \\ %{})

@spec valid_lti_claims(map()) :: map()

Return a complete, valid LTI claim set.

Caller can override individual claims via the overrides map.

verify_deep_linking_response(platform, jwt)

@spec verify_deep_linking_response(Ltix.Test.Platform.t(), String.t()) ::
  {:ok, map()} | {:error, term()}

Verify a Deep Linking response JWT signed by the tool.

Decodes the JWT using the tool's public key (derived from registration.tool_jwk) and returns the parsed claims on success.

{:ok, response} = Ltix.DeepLinking.build_response(context, items)
{:ok, claims} = Ltix.Test.verify_deep_linking_response(platform, response.jwt)
assert claims["https://purl.imsglobal.org/spec/lti/claim/message_type"] ==
         "LtiDeepLinkingResponse"