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!()}
endYour 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}
endController 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"
endUnit 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)
endHand-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
endSee 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.
Build POST params for Ltix.handle_callback/3.
Build POST params for Ltix.handle_login/3.
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 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
:registrationis a required 1-arity factory. Receives a validLtix.Registrationand returns your stored record implementingLtix.Registerable. Preserve the supplied issuer and client ID.:deploymentis a required 2-arity factory. Receives anLtix.Deploymentand your registration record. Returns the stored record implementingLtix.Deployable. Preserve the supplied deployment ID. Lookups must return the same stored record.:registration_identityand:deployment_identityare 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.:jwksis a 1-arity function that receives a registration record and returns the key set your tool publishes for it, such as the result ofLtix.JWK.to_jwks/1. Required when the adapter exportskey_id/2andsign/4, and rejected when it exports neither. The key set must include the keykey_id/2returns, without private keys.:storage_adapter_optionsis the keyword list each callback receives as its last argument. Defaults to[].:prepare_processis 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_deploymentsdefaults tofalse, requiring missing deployments to return{:error, :not_found}. Set totruefor 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.:timeoutis the positive timeout in milliseconds for concurrent process preparation and validation. Defaults to5000.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
@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_linkingfor a Deep Linking request. The default value is:resource_link.:roles(list ofatom/0|String.t/0| struct of typeLtix.LaunchClaims.Role) - Role atoms such as:instructor,%Ltix.LaunchClaims.Role{}structs, or role URIs. URIs Ltix does not recognize are listed inunrecognized_roles. The default value is[:instructor].:subject(nil|String.t/0) - User identifier. Defaults to"user-12345". Passnilfor 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 typeLtix.LaunchClaims.Context|map/0) - Course or section, as a%Ltix.LaunchClaims.Context{}or a map of its fields. The:iddefaults to"context-001".:resource_link(struct of typeLtix.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 typeLtix.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 typeLtix.LaunchClaims.AGSEndpoint|map/0) - Grade service endpoint, as a%Ltix.LaunchClaims.AGSEndpoint{}or a map of its fields. Requires:scopes.:memberships_endpoint(struct of typeLtix.LaunchClaims.MembershipsEndpoint|String.t/0|map/0) - Memberships service endpoint, as a%Ltix.LaunchClaims.MembershipsEndpoint{}, its URL, or a map of its fields.:service_versionsdefaults to["2.0"].:claims(map/0) - Raw claims merged over the built claims, keyed by claim name.
Extract the nonce from a login redirect URI.
{:ok, result} = Ltix.handle_login(params, redirect_uri)
nonce = Ltix.Test.extract_nonce(result.redirect_uri)
@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 withextract_nonce/1.:state(String.t/0) - Required. State from the login result.:message_type(:resource_link|:deep_linking) -:deep_linkingfor a Deep Linking request. The default value is:resource_link.:roles(list ofatom/0|String.t/0| struct of typeLtix.LaunchClaims.Role) - Role atoms such as:instructor,%Ltix.LaunchClaims.Role{}structs, or role URIs. URIs Ltix does not recognize are listed inunrecognized_roles. The default value is[:instructor].:subject(nil|String.t/0) - User identifier. Defaults to"user-12345". Passnilfor 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 typeLtix.LaunchClaims.Context|map/0) - Course or section, as a%Ltix.LaunchClaims.Context{}or a map of its fields. The:iddefaults to"context-001".:resource_link(struct of typeLtix.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 typeLtix.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 typeLtix.LaunchClaims.AGSEndpoint|map/0) - Grade service endpoint, as a%Ltix.LaunchClaims.AGSEndpoint{}or a map of its fields. Requires:scopes.:memberships_endpoint(struct of typeLtix.LaunchClaims.MembershipsEndpoint|String.t/0|map/0) - Memberships service endpoint, as a%Ltix.LaunchClaims.MembershipsEndpoint{}, its URL, or a map of its fields.:service_versionsdefaults to["2.0"].:claims(map/0) - Raw claims merged over the built claims, keyed by claim name.
@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")
@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 implementingLtix.Registerable(mutually exclusive with:issuerand:client_id), or a 1-arity function that receives anLtix.Registrationand 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:issuerand:client_idoverrides.:deployment- either an app struct implementingLtix.Deployable(mutually exclusive with:deployment_id), or a 2-arity function(Ltix.Deployment, registration)whereregistrationis whatever the registration step returned. Lets you create a deployment record linked to your registration.:tool_jwk- theLtix.JWKyour tool signs with for this platform. Pass the key your own storage adapter signs with. Defaults to the keyLtix.Test.StorageAdaptersigns with.
@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'skid. Passnilto leave it out.:alg(String.t/0) - Signing algorithm. The default value is"RS256".
@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}
)
@spec stub_delete_line_item(keyword()) :: :ok
Stub delete_line_item/2 to succeed.
Ltix.Test.stub_delete_line_item()Options
:line_item(String.t/0| struct of typeLtix.GradeService.LineItem) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.
@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
:line_item(String.t/0| struct of typeLtix.GradeService.LineItem) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.
@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.
@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 typeLtix.LaunchClaims.Context|map/0) - Course or section, as a%Ltix.LaunchClaims.Context{}or a map of its fields. The:iddefaults to"context-001".:id(String.t/0) - Container ID URL.
@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
:line_item(String.t/0| struct of typeLtix.GradeService.LineItem) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.
@spec stub_post_score(keyword()) :: :ok
Stub post_score/3 to succeed.
Ltix.Test.stub_post_score()Options
:line_item(String.t/0| struct of typeLtix.GradeService.LineItem) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.
@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 to3600)
@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
:line_item(String.t/0| struct of typeLtix.GradeService.LineItem) - Line item URL or struct. Limits the stub to requests for this line item, and replaces only the stub for it.
@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)
@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"