Ltix.Test (Ltix v0.2.0)

Copy Markdown View Source

Helpers for testing applications that use Ltix.

Reduces LTI test setup to a single call. setup_platform!/1 generates RSA keys, builds JWKS payloads, and creates registrations and deployments.

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

Your application's own storage adapter (configured in config/test.exs) handles persistence. setup_platform!/1 provides the platform-side simulation, which is RSA keys, a JWKS endpoint stub, and registration/deployment data your adapter can look up. Without an adapter of your own, start the in-memory Ltix.Test.StorageAdapter with the platform.

Add the optional :plug dependency to your test environment to use setup_platform!/1 and HTTP stubs. The stubs take effect only when Ltix sends its requests through Req.Test, so add this to config/test.exs.

config :ltix, req_options: [plug: {Req.Test, :ltix}]

Connecting to your storage adapter

Pass a :registration function to create records in your own persistence layer. The function receives a valid Ltix.Registration with the platform details and returns your app's struct (which must implement Ltix.Registerable).

setup do
  jwk = MyApp.Lti.generate_jwk!()

  platform = Ltix.Test.setup_platform!(
    registration: fn reg ->
      MyApp.Lti.create_registration!(%{
        issuer: reg.issuer,
        client_id: reg.client_id,
        auth_endpoint: reg.auth_endpoint,
        jwks_uri: reg.jwks_uri,
        token_endpoint: reg.token_endpoint,
        jwk_id: jwk.id
      })
    end,
    tool_jwk: Ltix.JWK.new!(private_key_pem: jwk.private_key_pem, kid: jwk.kid),
    deployment: fn dep, my_reg ->
      MyApp.Lti.create_deployment!(%{
        deployment_id: dep.deployment_id,
        registration_id: my_reg.id
      })
    end
  )

  %{platform: platform}
end

Controller tests (full OIDC flow)

Simulate a platform-initiated launch against your controller endpoints. Your app's storage adapter resolves registrations and nonces as it would in production.

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

Hand-built tokens

To send a token launch_params/2 doesn't build, start from valid_lti_claims/2, change the claims, and sign them with the platform's key using sign_id_token/3.

claims = Map.delete(Ltix.Test.valid_lti_claims(platform, %{"nonce" => nonce}), "sub")
params = %{"id_token" => Ltix.Test.sign_id_token(claims, platform.jwk), "state" => state}

Advantage service tests

When testing code that calls Ltix.GradeService or Ltix.MembershipsService, stub the OAuth token endpoint and each operation your code calls. Each stub handles one operation, stubbing an operation again replaces its stub, and a request without a matching stub fails the test with its method and URL.

setup do
  platform = Ltix.Test.setup_platform!()

  Ltix.Test.stub_token_response(scopes: [
    "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
    "https://purl.imsglobal.org/spec/lti-ags/scope/score"
  ])

  Ltix.Test.stub_list_line_items([
    %Ltix.GradeService.LineItem{
      id: "https://platform.example.com/lineitems/1",
      label: "Quiz",
      score_maximum: 100
    }
  ])

  Ltix.Test.stub_post_score()

  %{platform: platform}
end

test "posts a score to every line item", %{platform: platform} do
  context = Ltix.Test.build_launch_context(platform,
    ags_endpoint: %{
      line_items: "https://platform.example.com/lineitems",
      scopes: [
        "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
        "https://purl.imsglobal.org/spec/lti-ags/scope/score"
      ]
    }
  )

  {:ok, client} = Ltix.GradeService.authenticate(context)

  score =
    Ltix.GradeService.Score.new!(
      user_id: "student-42",
      score_given: 85,
      score_maximum: 100,
      activity_progress: :completed,
      grading_progress: :fully_graded
    )

  for item <- Ltix.GradeService.list_line_items!(client) do
    :ok = Ltix.GradeService.post_score(client, score, line_item: item)
  end
end

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

Summary

Functions

Assert that a storage adapter satisfies Ltix.StorageAdapter.

Build a %LaunchContext{} directly for unit testing.

Extract the nonce from a login redirect URI.

Set up a simulated LTI platform in one call.

Sign claims as an ID token with a platform key.

Stub create_line_item/2 to return the given line item.

Stub delete_line_item/2 to succeed.

Stub get_line_item/2 to return the given line item.

Stub list_line_items/2 and stream_line_items/2 to return the given line items.

Stub list_members/2 and stream_members/2 to return the given members.

Stub list_results/2 and stream_results/2 to return the given results.

Stub post_score/3 to succeed.

Stub the OAuth token endpoint for advantage service tests.

Stub update_line_item/2 to return the given line item.

Return a complete, valid LTI claim set.

Verify a Deep Linking response JWT signed by the tool.

Functions

assert_storage_adapter!(adapter, opts)

@spec assert_storage_adapter!(module(), keyword()) :: :ok

Assert that a storage adapter satisfies Ltix.StorageAdapter.

Checks registration and deployment lookups, returned records, and nonce replay prevention. For an adapter that exports key_id/2 and sign/4, also checks that its signatures verify against your published key set and that it returns an error for a key ID it can't sign with. Returns :ok or raises ExUnit.AssertionError with the failing contract check.

Two parts of the contract can't be checked from outside your storage, that nonce records are kept until expires_at and that storage failures return {:error, reason}.

The deployment, nonce, and signing callbacks receive the registration record your adapter's get_registration/3 returns, as they do in a launch.

Use isolated test storage and your application's normal test cleanup.

Options

  • :registration is a required 1-arity factory. Receives a valid Ltix.Registration and returns your stored record implementing Ltix.Registerable. Preserve the supplied issuer and client ID.

  • :deployment is a required 2-arity factory. Receives an Ltix.Deployment and your registration record. Returns the stored record implementing Ltix.Deployable. Preserve the supplied deployment ID. Lookups must return the same stored record.

  • :registration_identity and :deployment_identity are optional 1-arity functions that return a record's storage identity, such as & &1.id. By default, the complete factory and lookup records must match. Use these functions when lookups preload associations or change metadata. Protocol-converted fields must still match.

  • :jwks is a 1-arity function that receives a registration record and returns the key set your tool publishes for it, such as the result of Ltix.JWK.to_jwks/1. Required when the adapter exports key_id/2 and sign/4, and rejected when it exports neither. The key set must include the key key_id/2 returns, without private keys.

  • :storage_adapter_options is the keyword list each callback receives as its last argument. Defaults to [].

  • :prepare_process is a 0-arity function called in each concurrent callback process. Use it to allow database sandbox access or set process-local adapter state. Defaults to a no-op.

  • :auto_create_deployments defaults to false, requiring missing deployments to return {:error, :not_found}. Set to true for adapters that create a deployment when none exists. Return distinct storage identities to check that registrations with the same deployment ID get separate automatically created deployments.

  • :timeout is the positive timeout in milliseconds for concurrent process preparation and validation. Defaults to 5000.

    test "storage adapter satisfies the contract" do parent = self()

    :ok = Ltix.Test.assert_storage_adapter!(MyApp.LtiStorage,

    registration: fn registration ->
      MyApp.Lti.create_registration!(Map.from_struct(registration))
    end,
    deployment: fn deployment, registration ->
      MyApp.Lti.create_deployment!(%{
        deployment_id: deployment.deployment_id,
        registration_id: registration.id
      })
    end,
    prepare_process: fn ->
      Ecto.Adapters.SQL.Sandbox.allow(MyApp.Repo, parent, self())
    end

    ) end

build_launch_context(platform, opts \\ [])

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

Build a %LaunchContext{} directly for unit testing.

Returns the context Ltix.handle_callback/3 would return for a launch from launch_params/2 with the same launch options, without the OIDC flow.

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"]}
)

Options

  • :message_type (:resource_link | :deep_linking) - :deep_linking for a Deep Linking request. The default value is :resource_link.

  • :roles (list of atom/0 | String.t/0 | struct of type Ltix.LaunchClaims.Role) - Role atoms such as :instructor, %Ltix.LaunchClaims.Role{} structs, or role URIs. URIs Ltix does not recognize are listed in unrecognized_roles. The default value is [:instructor].

  • :subject (nil | String.t/0) - User identifier. Defaults to "user-12345". Pass nil for an anonymous launch.

  • :name (String.t/0) - Full name.

  • :email (String.t/0) - Email address.

  • :given_name (String.t/0) - Given name.

  • :family_name (String.t/0) - Family name.

  • :target_link_uri (String.t/0) - Launch URL. The default value is "https://tool.example.com/launch".

  • :context (struct of type Ltix.LaunchClaims.Context | map/0) - Course or section, as a %Ltix.LaunchClaims.Context{} or a map of its fields. The :id defaults to "context-001".

  • :resource_link (struct of type Ltix.LaunchClaims.ResourceLink | map/0) - Resource link of a resource link launch, as a %Ltix.LaunchClaims.ResourceLink{} or a map of its fields. Defaults to %{id: "resource-link-001", title: "Example Assignment"}.

  • :deep_linking_settings (struct of type Ltix.LaunchClaims.DeepLinkingSettings | map/0) - Settings of a Deep Linking request, as a %Ltix.LaunchClaims.DeepLinkingSettings{} or a map of its fields. Fields you leave out accept the five standard content types, every presentation target, and multiple items.

  • :ags_endpoint (struct of type Ltix.LaunchClaims.AGSEndpoint | map/0) - Grade service endpoint, as a %Ltix.LaunchClaims.AGSEndpoint{} or a map of its fields. Requires :scopes.

  • :memberships_endpoint (struct of type Ltix.LaunchClaims.MembershipsEndpoint | String.t/0 | map/0) - Memberships service endpoint, as a %Ltix.LaunchClaims.MembershipsEndpoint{}, its URL, or a map of its fields. :service_versions defaults to ["2.0"].

  • :claims (map/0) - Raw claims merged over the built claims, keyed by claim name.

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)

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}. Takes the same launch options as build_launch_context/2, plus the :nonce and :state of the login.

Options

  • :nonce (String.t/0) - Required. Nonce from the login redirect, read with extract_nonce/1.

  • :state (String.t/0) - Required. State from the login result.

  • :message_type (:resource_link | :deep_linking) - :deep_linking for a Deep Linking request. The default value is :resource_link.

  • :roles (list of atom/0 | String.t/0 | struct of type Ltix.LaunchClaims.Role) - Role atoms such as :instructor, %Ltix.LaunchClaims.Role{} structs, or role URIs. URIs Ltix does not recognize are listed in unrecognized_roles. The default value is [:instructor].

  • :subject (nil | String.t/0) - User identifier. Defaults to "user-12345". Pass nil for an anonymous launch.

  • :name (String.t/0) - Full name.

  • :email (String.t/0) - Email address.

  • :given_name (String.t/0) - Given name.

  • :family_name (String.t/0) - Family name.

  • :target_link_uri (String.t/0) - Launch URL. The default value is "https://tool.example.com/launch".

  • :context (struct of type Ltix.LaunchClaims.Context | map/0) - Course or section, as a %Ltix.LaunchClaims.Context{} or a map of its fields. The :id defaults to "context-001".

  • :resource_link (struct of type Ltix.LaunchClaims.ResourceLink | map/0) - Resource link of a resource link launch, as a %Ltix.LaunchClaims.ResourceLink{} or a map of its fields. Defaults to %{id: "resource-link-001", title: "Example Assignment"}.

  • :deep_linking_settings (struct of type Ltix.LaunchClaims.DeepLinkingSettings | map/0) - Settings of a Deep Linking request, as a %Ltix.LaunchClaims.DeepLinkingSettings{} or a map of its fields. Fields you leave out accept the five standard content types, every presentation target, and multiple items.

  • :ags_endpoint (struct of type Ltix.LaunchClaims.AGSEndpoint | map/0) - Grade service endpoint, as a %Ltix.LaunchClaims.AGSEndpoint{} or a map of its fields. Requires :scopes.

  • :memberships_endpoint (struct of type Ltix.LaunchClaims.MembershipsEndpoint | String.t/0 | map/0) - Memberships service endpoint, as a %Ltix.LaunchClaims.MembershipsEndpoint{}, its URL, or a map of its fields. :service_versions defaults to ["2.0"].

  • :claims (map/0) - Raw claims merged over the built claims, keyed by claim name.

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 (defaults to "user-hint")
  • :target_link_uri - launch URL (defaults to "https://tool.example.com/launch")

setup_platform!(opts \\ [])

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

Set up a simulated LTI platform in one call.

Generates platform-side RSA keys, builds a registration and deployment, and stubs the JWKS HTTP endpoint. The configured Ltix.JWT.KeySet must be started. Your app's own storage adapter (configured via config :ltix, storage_adapter: ...) handles persistence during the OIDC flow.

Options

  • :issuer - platform issuer URL (defaults to "https://platform.example.com")
  • :client_id - OAuth client ID (defaults to "tool-client-id")
  • :deployment_id - deployment identifier (defaults to "deployment-001")
  • :registration - either an app struct implementing Ltix.Registerable (mutually exclusive with :issuer and :client_id), or a 1-arity function that receives an Ltix.Registration and returns your app's struct. The function form lets you create a matching record in your own persistence layer using the platform details (issuer, client_id, endpoints). Works with :issuer and :client_id overrides.
  • :deployment - either an app struct implementing Ltix.Deployable (mutually exclusive with :deployment_id), or a 2-arity function (Ltix.Deployment, registration) where registration is whatever the registration step returned. Lets you create a deployment record linked to your registration.
  • :tool_jwk - the Ltix.JWK your tool signs with for this platform. Pass the key your own storage adapter signs with. Defaults to the key Ltix.Test.StorageAdapter signs with.

sign_id_token(claims, jwk, opts \\ [])

@spec sign_id_token(map(), Ltix.JWK.t(), keyword()) :: String.t()

Sign claims as an ID token with a platform key.

Use it to build launch tokens by hand, such as a token missing a claim.

claims = Map.delete(Ltix.Test.valid_lti_claims(platform), "sub")
id_token = Ltix.Test.sign_id_token(claims, platform.jwk)

Options

  • :kid (nil | String.t/0) - Key ID for the token header. Defaults to the key's kid. Pass nil to leave it out.

  • :alg (String.t/0) - Signing algorithm. The default value is "RS256".

stub_create_line_item(item)

@spec stub_create_line_item(Ltix.GradeService.LineItem.t()) :: :ok

Stub create_line_item/2 to return the given line item.

Pass the line item you want the platform to "return" (typically the same fields the caller sent, plus an :id assigned by the platform).

Ltix.Test.stub_create_line_item(
  %LineItem{id: "https://lms.example.com/lineitems/new", label: "Quiz 1", score_maximum: 100}
)

stub_delete_line_item(opts \\ [])

@spec stub_delete_line_item(keyword()) :: :ok

Stub delete_line_item/2 to succeed.

Ltix.Test.stub_delete_line_item()

Options

stub_get_line_item(item, opts \\ [])

@spec stub_get_line_item(Ltix.GradeService.LineItem.t(), keyword()) :: :ok

Stub get_line_item/2 to return the given line item.

Ltix.Test.stub_get_line_item(
  %LineItem{id: "https://lms.example.com/lineitems/1", label: "Quiz 1", score_maximum: 100}
)

Options

stub_list_line_items(line_items)

@spec stub_list_line_items([Ltix.GradeService.LineItem.t()]) :: :ok

Stub list_line_items/2 and stream_line_items/2 to return the given line items.

Ltix.Test.stub_list_line_items([
  %LineItem{id: "https://lms.example.com/lineitems/1", label: "Quiz 1", score_maximum: 100},
  %LineItem{id: "https://lms.example.com/lineitems/2", label: "Quiz 2", score_maximum: 50}
])

Responds to a request with a :per_page size one page at a time, linking to the next page like a platform does.

stub_list_members(members, opts \\ [])

@spec stub_list_members([Ltix.MembershipsService.Member.t()], keyword()) :: :ok

Stub list_members/2 and stream_members/2 to return the given members.

Ltix.Test.stub_list_members([
  %Member{user_id: "student-1", roles: [Role.from_atom(:learner)], name: "Alice"},
  %Member{user_id: "student-2", roles: [Role.from_atom(:instructor)], name: "Bob"}
])

Responds to a request with a :per_page size one page at a time, linking to the next page like a platform does.

Options

  • :context (struct of type Ltix.LaunchClaims.Context | map/0) - Course or section, as a %Ltix.LaunchClaims.Context{} or a map of its fields. The :id defaults to "context-001".

  • :id (String.t/0) - Container ID URL.

stub_list_results(results, opts \\ [])

@spec stub_list_results([Ltix.GradeService.Result.t()], keyword()) :: :ok

Stub list_results/2 and stream_results/2 to return the given results.

Ltix.Test.stub_list_results([
  %Result{user_id: "student-1", result_score: 0.85, result_maximum: 1},
  %Result{user_id: "student-2", result_score: 0.92, result_maximum: 1}
])

Responds to a request with a :per_page size one page at a time, linking to the next page like a platform does.

Options

stub_post_score(opts \\ [])

@spec stub_post_score(keyword()) :: :ok

Stub post_score/3 to succeed.

Ltix.Test.stub_post_score()

Options

stub_token_response(opts \\ [])

@spec stub_token_response(keyword()) :: :ok

Stub the OAuth token endpoint for advantage service tests.

Stubs the OAuth token endpoint so advantage service tests don't make real HTTP calls. Call this in your test setup before authenticating a service client.

Ltix.Test.stub_token_response(scopes: [
  "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
  "https://purl.imsglobal.org/spec/lti-ags/scope/score"
])

Options

  • :scopes - list of granted scope URIs (defaults to []). An empty list grants the requested scopes.
  • :access_token - token string (defaults to "test-token")
  • :expires_in - token lifetime in seconds (defaults to 3600)

stub_update_line_item(item, opts \\ [])

@spec stub_update_line_item(Ltix.GradeService.LineItem.t(), keyword()) :: :ok

Stub update_line_item/2 to return the given line item.

Ltix.Test.stub_update_line_item(
  %LineItem{id: "https://lms.example.com/lineitems/1", label: "Updated Quiz", score_maximum: 100}
)

Options

valid_lti_claims(platform, overrides \\ %{})

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

Return a complete, valid LTI claim set.

The issuer, audience, and deployment ID are the platform's. Caller can override individual claims via the overrides map.

claims = Ltix.Test.valid_lti_claims(platform, %{"nonce" => nonce})
id_token = Ltix.Test.sign_id_token(claims, platform.jwk)

verify_deep_linking_response(platform, jwt)

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

Verify a Deep Linking response JWT signed by the tool.

Decodes the JWT using the public key of the platform's tool_jwk and returns the parsed claims on success. Returns Ltix.Errors.Security.SignatureInvalid when the token is malformed or was not signed with the tool's key.

{: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"