mod utils;
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
use iri_string::types::IriStr;
use josekit::jws::{self, JwsHeader, RS256};
use openssl::{sha::Sha256, x509::X509};
use rocket::http::Status;
use serde_json::{Map, Value};
use std::str::FromStr;
use test_context::test_context;
use tracing_test::traced_test;
use utils::{
accept_json, authorization, boundary_delimiter_line, content_type, multipart, read_to_string,
v2, MyTestContext, BOUNDARY, CR_LF,
};
use uuid::{uuid, Uuid};
use xapi_rs::{
adl_verb, config, Account, Activity, ActivityDefinition, Actor, Agent, Attachment, Group,
MyDuration, MyError, MyLanguageTag, MyTimestamp, MyVersion, Score, Statement, StatementObject,
StatementRef, Verb, Vocabulary, XResult, SIGNATURE_CT, SIGNATURE_UT,
};
const ID1: Uuid = uuid!("fd41c918-b88b-4b20-a0a5-a4c32391aaa0");
const URL1: &str = "http://example.com/xapi/verbs#sent-a-statement";
const ID2: Uuid = uuid!("7ccd3322-e1a5-411a-a67d-6a735c76f119");
const URL2: &str = "http://adlnet.gov/expapi/verbs/attempted";
#[test]
fn test_simple_statement() -> Result<(), MyError> {
let from_json = read_to_string("statement-simple", true);
let de_result = serde_json::from_str::<Statement>(&from_json);
assert!(de_result.is_ok());
let st = de_result.unwrap();
let id = st.id();
assert!(id.is_some());
assert_eq!(id.unwrap(), &ID1);
let actor = st.actor();
assert!(actor.is_agent());
assert!(!actor.is_group());
let agent_from_statement = actor.as_agent().unwrap();
let an = agent_from_statement.name();
assert!(an.is_some());
assert_eq!(an.unwrap(), "Project Tin Can API");
let email = agent_from_statement.mbox();
assert!(email.is_some());
assert_eq!(email.as_ref().unwrap().to_uri(), "mailto:user@example.com");
assert_eq!(email.unwrap().to_string(), "user@example.com");
assert!(agent_from_statement.mbox_sha1sum().is_none());
assert!(agent_from_statement.account().is_none());
assert!(agent_from_statement.openid().is_none());
let agent = Agent::builder()
.with_object_type()
.name("Project Tin Can API")?
.mbox("user@example.com")?
.build()?;
assert_eq!(agent, agent_from_statement);
let verb_from_statement = st.verb();
assert_eq!(verb_from_statement.id(), URL1);
let en = MyLanguageTag::from_str("en")?;
let us = MyLanguageTag::from_str("en-US")?;
let fr = MyLanguageTag::from_str("fr")?;
assert!(verb_from_statement.display(&en).is_none());
assert!(verb_from_statement.display(&us).is_some());
assert_eq!(verb_from_statement.display(&us).unwrap(), "sent");
let verb = Verb::builder().id(URL1)?.display(&us, "sent")?.build()?;
assert_eq!(&verb, verb_from_statement);
let object = st.object();
assert!(object.is_activity());
assert!(!object.is_sub_statement());
assert!(!object.is_agent());
assert!(!object.is_group());
assert!(!object.is_statement_ref());
let activity_from_statement = object.as_activity().unwrap();
assert_eq!(
activity_from_statement.id(),
"http://example.com/xapi/activity/simplestatement"
);
assert!(activity_from_statement.definition().is_some());
let definition_from_statement = activity_from_statement.definition().unwrap();
assert!(definition_from_statement.name(&en).is_none());
assert!(definition_from_statement.name(&us).is_some());
assert_eq!(
definition_from_statement.name(&us).unwrap(),
"simple statement"
);
assert!(definition_from_statement.description(&fr).is_none());
assert!(definition_from_statement.description(&us).is_some());
assert_eq!(
definition_from_statement.description(&us).unwrap().len(),
159
);
assert!(definition_from_statement.type_().is_none());
assert!(definition_from_statement.more_info().is_none());
assert!(definition_from_statement.interaction_type().is_none());
assert!(definition_from_statement
.correct_responses_pattern()
.is_none());
assert!(definition_from_statement.choices().is_none());
assert!(definition_from_statement.scale().is_none());
assert!(definition_from_statement.source().is_none());
assert!(definition_from_statement.target().is_none());
assert!(definition_from_statement.steps().is_none());
let activity_definition = ActivityDefinition::builder()
.name(&us, "simple statement")?
.description(
&us,
r#"A simple Experience API statement. Note that the LRS
does not need to have any prior information about the Actor (learner), the
verb, or the Activity/object."#,
)?
.build()?;
assert_eq!(&activity_definition, definition_from_statement);
let activity = Activity::builder()
.id("http://example.com/xapi/activity/simplestatement")?
.definition(activity_definition)?
.build()?;
assert_eq!(activity, activity_from_statement);
assert!(st.timestamp().is_some());
assert_eq!(
st.timestamp().unwrap().to_rfc3339(),
"2015-11-18T12:17:00+00:00"
);
let ts_result = DateTime::parse_from_rfc3339(&st.timestamp().unwrap().to_rfc3339());
assert!(ts_result.is_ok());
let ts = ts_result.unwrap().with_timezone(&Utc);
let date = ts.date_naive();
assert_eq!(date.to_string(), "2015-11-18");
let time = ts.time();
assert_eq!(time.to_string(), "12:17:00");
assert_eq!(time, NaiveTime::from_hms_milli_opt(12, 17, 0, 0).unwrap());
assert!(st.result().is_none());
assert!(st.context().is_none());
assert!(st.stored().is_none());
assert!(st.authority().is_none());
assert!(st.version().is_none());
assert!(st.attachments().is_empty());
let se_result = serde_json::to_string(&st);
assert!(se_result.is_ok());
let to_json = se_result.unwrap();
let mut raw: Map<String, Value> = serde_json::from_str(&from_json).unwrap();
println!("raw = {:?}", raw);
let mut cooked: Map<String, Value> = serde_json::from_str(&to_json).unwrap();
println!("cooked = {:?}", cooked);
let raw_ts = raw.remove("timestamp").unwrap();
let cooked_ts = cooked.remove("timestamp").unwrap();
assert_eq!(cooked, raw);
let raw_dt = DateTime::parse_from_str(
&raw_ts.as_str().as_ref().unwrap().trim_matches('"'),
"%Y-%m-%dT%H:%M:%S%:z",
);
assert!(raw_dt.is_ok());
let raw_dt = raw_dt.unwrap().naive_utc();
let cooked_dt = MyTimestamp::from_str(cooked_ts.as_str().unwrap());
assert!(cooked_dt.is_ok());
let cooked_dt = cooked_dt.unwrap().inner().naive_utc();
assert_eq!(raw_dt, cooked_dt);
Ok(())
}
#[traced_test]
#[test]
fn test_statement_w_attempted() -> Result<(), MyError> {
let json = read_to_string("statement-simpleCBT", true);
let de_result = serde_json::from_str::<Statement>(&json);
assert!(de_result.is_ok());
let st = de_result.unwrap();
let id = st.id();
assert!(id.is_some());
assert_eq!(id.unwrap(), &ID2);
let actor = st.actor();
assert!(actor.is_agent());
assert!(!actor.is_group());
let agent = actor.as_agent().unwrap();
let an = agent.name();
assert!(an.is_some());
assert_eq!(an.unwrap(), "Example Learner");
let email = agent.mbox();
assert!(email.is_some());
assert_eq!(
email.as_ref().unwrap().to_string(),
"example.learner@adlnet.gov"
);
assert_eq!(email.unwrap().to_uri(), "mailto:example.learner@adlnet.gov");
assert!(agent.mbox_sha1sum().is_none());
assert!(agent.account().is_none());
assert!(agent.openid().is_none());
let agent = Agent::builder()
.with_object_type()
.name("Example Learner")?
.mbox("example.learner@adlnet.gov")?
.build()?;
assert_eq!(agent, st.actor().as_agent()?);
let en = MyLanguageTag::from_str("en")?;
let us = MyLanguageTag::from_str("en-US")?;
let fr = MyLanguageTag::from_str("fr")?;
let verb = st.verb();
assert_eq!(verb.id(), URL2);
assert!(verb.display(&us).is_some());
assert_eq!(verb.display(&us).unwrap(), "attempted");
let verb = Verb::builder()
.id(URL2)?
.display(&us, "attempted")?
.build()?;
assert_eq!(&verb, st.verb());
let attempted = adl_verb(Vocabulary::Attempted);
assert_ne!(&verb, attempted);
assert!(verb.equivalent(attempted));
let object = st.object();
assert!(object.is_activity());
assert!(!object.is_sub_statement());
assert!(!object.is_agent());
assert!(!object.is_group());
assert!(!object.is_statement_ref());
let activity = object.as_activity().unwrap();
assert_eq!(
activity.id(),
"http://example.adlnet.gov/xapi/example/simpleCBT"
);
assert!(activity.definition().is_some());
let definition = activity.definition().unwrap();
assert!(definition.name(&en).is_none());
assert!(definition.name(&us).is_some());
assert_eq!(definition.name(&us).unwrap(), "simple CBT course");
assert!(definition.description(&fr).is_none());
assert!(definition.description(&us).is_some());
assert_eq!(
definition.description(&us).unwrap(),
"A fictitious example CBT course."
);
assert!(definition.type_().is_none());
assert!(definition.more_info().is_none());
assert!(definition.interaction_type().is_none());
assert!(definition.correct_responses_pattern().is_none());
assert!(definition.choices().is_none());
assert!(definition.scale().is_none());
assert!(definition.source().is_none());
assert!(definition.target().is_none());
assert!(definition.steps().is_none());
assert!(st.timestamp().is_some());
assert_eq!(
st.timestamp().unwrap().to_rfc3339(),
"2015-12-18T12:17:00+00:00"
);
let ts_result = DateTime::parse_from_rfc3339(&st.timestamp().unwrap().to_rfc3339());
assert!(ts_result.is_ok());
let ts = ts_result.unwrap().with_timezone(&Utc);
let date = ts.date_naive();
let time = ts.time();
assert_eq!(date.to_string(), "2015-12-18");
assert_eq!(time.to_string(), "12:17:00");
let timestamp = Utc.with_ymd_and_hms(2015, 12, 18, 12, 17, 00).unwrap();
assert_eq!(st.timestamp().unwrap(), ×tamp);
assert!(st.result().is_some());
let result = st.result().unwrap();
assert!(result.score().is_some());
let score = result.score().unwrap();
assert!(score.scaled().is_some());
assert_eq!(score.scaled().unwrap(), 0.95);
assert!(score.raw().is_none());
assert!(score.min().is_none());
assert!(score.max().is_none());
assert!(result.success().is_some());
assert!(result.completion().unwrap());
assert!(result.completion().is_some());
assert!(result.completion().unwrap());
assert!(result.duration().is_some());
let duration = MyDuration::new(true, 0, 1234, 0).unwrap();
assert_eq!(result.duration().unwrap(), &duration);
assert!(result.response().is_none());
assert!(result.extensions().is_none());
assert!(st.context().is_none());
assert!(st.stored().is_none());
assert!(st.authority().is_none());
assert!(st.version().is_none());
assert!(st.attachments().is_empty());
let uuid = uuid!("7ccd3322e1a5411aa67d6a735c76f119");
let agent = Agent::builder()
.name("Example Learner")?
.mbox("example.learner@adlnet.gov")?
.build()?;
tracing::debug!("agent = {}", agent);
let verb = Verb::builder()
.id("http://adlnet.gov/expapi/verbs/attempted")?
.display(&us, "")?
.build()?;
tracing::debug!("verb = {}", verb);
let definition = ActivityDefinition::builder()
.name(&us, "simple CBT course")?
.description(&us, "A fictitious example CBT course.")?
.build()?;
tracing::debug!("definition = {}", definition);
let activity = Activity::builder()
.id("http://example.adlnet.gov/xapi/example/simpleCBT")?
.definition(definition)?
.build()?;
tracing::debug!("activity = {}", activity);
let score = Score::builder().scaled(0.95)?.build()?;
tracing::debug!("score = {}", score);
let result = XResult::builder()
.score(score)?
.success(true)
.completion(true)
.duration("PT1234S")?
.build()?;
tracing::debug!("result = {}", result);
let statement = Statement::builder()
.id(&uuid.to_string())?
.actor(Actor::Agent(agent))?
.verb(verb)?
.object(StatementObject::Activity(activity))?
.result(result)?
.timestamp("2015-12-18T12:17:00+00:00")?
.build()?;
tracing::debug!("statement = {}", statement);
assert_ne!(statement, st);
assert!(statement.equivalent(&st));
Ok(())
}
#[traced_test]
#[test]
fn test_long_statement() -> Result<(), MyError> {
let json = read_to_string("statement-long", true);
let de_result = serde_json::from_str::<Statement>(&json);
assert!(de_result.is_ok());
let st = de_result.unwrap();
{
let actor = st.actor();
assert!(actor.is_group());
assert!(!actor.is_agent());
let group_from_statement = actor.as_group().unwrap();
assert_eq!(group_from_statement.members().len(), 3);
let larry = Agent::builder()
.name("Andrew Downes")?
.account(
Account::builder()
.home_page("http://www.example.com")?
.name("13936749")?
.build()?,
)?
.build()?;
let curly = Agent::builder()
.name("Toby Nichols")?
.openid("http://toby.openid.example.org/")?
.build()?;
let moe = Agent::builder()
.name("Ena Hills")?
.mbox_sha1sum("ebd31e95054c018b10727ccffd2ef2ec3a016ee9")?
.build()?;
let group = Group::builder()
.name("Team PB")?
.mbox("teampb@example.com")?
.member(moe)?
.member(curly)?
.member(larry)?
.build()?;
assert!(group_from_statement.equivalent(&group));
}
{
let object = st.object();
assert!(object.is_activity());
assert!(!object.is_sub_statement());
assert!(!object.is_agent());
assert!(!object.is_group());
assert!(!object.is_statement_ref());
let activity = object.as_activity().unwrap();
assert_eq!(
activity.id(),
"http://www.example.com/meetings/occurances/34534"
);
assert!(activity.definition().is_some());
let definition = activity.definition().unwrap();
let en = MyLanguageTag::from_str("en")?;
let us = MyLanguageTag::from_str("en-US")?;
let gb = MyLanguageTag::from_str("en-GB")?;
let fr = MyLanguageTag::from_str("fr")?;
assert!(definition.name(&en).is_none());
assert!(definition.name(&us).is_some());
assert_eq!(definition.name(&us).unwrap(), "example meeting");
assert!(definition.description(&fr).is_none());
assert!(definition.description(&gb).is_some());
assert_eq!(
definition.description(&gb).unwrap(),
"An example meeting that happened on a specific occasion with certain people present."
);
assert!(definition.type_().is_some());
assert_eq!(
definition.type_().unwrap(),
"http://adlnet.gov/expapi/activities/meeting"
);
assert!(definition.more_info().is_some());
assert_eq!(
definition.more_info().unwrap(),
"http://virtualmeeting.example.com/345256"
);
assert!(definition.interaction_type().is_none());
assert!(definition.correct_responses_pattern().is_none());
assert!(definition.choices().is_none());
assert!(definition.scale().is_none());
assert!(definition.source().is_none());
assert!(definition.target().is_none());
assert!(definition.steps().is_none());
assert!(definition.extensions().is_some());
assert_eq!(definition.extensions().unwrap().len(), 1);
let iri =
IriStr::new("http://example.com/profiles/meetings/activitydefinitionextensions/room")
.expect("Failed parsing IRI");
let ext = definition.extension(iri);
assert!(ext.is_some());
let actual_ext = serde_json::from_str::<Value>(
r#"{"name": "Kilby", "id" : "http://example.com/rooms/342"}"#,
);
assert_eq!(ext.unwrap(), &actual_ext.unwrap());
}
{
assert!(st.timestamp().is_some());
assert!(st.stored().is_some());
assert_eq!(st.timestamp().unwrap(), st.stored().unwrap());
let timestamp = NaiveDate::from_ymd_opt(2013, 5, 18)
.unwrap()
.and_hms_nano_opt(5, 32, 34, 804_000_000)
.unwrap()
.and_local_timezone(Utc)
.unwrap();
assert_eq!(st.timestamp().unwrap(), ×tamp);
}
{
assert!(st.authority().is_some());
let authority = Agent::builder()
.with_object_type()
.account(
Account::builder()
.home_page("http://cloud.scorm.com/")?
.name("anonymous")?
.build()?,
)?
.build()?;
assert_eq!(st.authority().unwrap(), &Actor::Agent(authority));
}
{
let version_from_statement = st.version().unwrap();
let version = MyVersion::from_str("1.0.0").unwrap();
assert_eq!(version_from_statement, &version);
}
{
assert!(st.result().is_some());
let result = st.result().unwrap();
assert_eq!(result.success().unwrap(), true);
assert_eq!(result.completion().unwrap(), true);
assert_eq!(
result.response().unwrap(),
"We agreed on some example actions."
);
let duration = MyDuration::new(true, 0, 60 * 60, 0).unwrap();
assert_eq!(result.duration().unwrap(), &duration);
}
{
assert!(st.context().is_some());
let ctx = st.context().unwrap();
assert_eq!(
ctx.registration().unwrap(),
&uuid!("ec531277b57b4c158d91d292c5b2b8f7")
);
assert!(ctx.instructor().is_some());
let instructor = Agent::builder()
.with_object_type()
.name("Andrew Downes")?
.account(
Account::builder()
.home_page("http://www.example.com")?
.name("13936749")?
.build()?,
)?
.build()?;
assert_eq!(ctx.instructor().unwrap(), &Actor::Agent(instructor));
assert!(ctx.team().is_some());
let team = Group::builder()
.name("Team PB")?
.mbox("teampb@example.com")?
.build()?;
assert_eq!(ctx.team().unwrap(), &team);
assert!(ctx.platform().is_some());
assert_eq!(ctx.platform().unwrap(), "Example virtual meeting software");
assert!(ctx.language().is_some());
assert_eq!(ctx.language().unwrap(), "tlh");
assert!(ctx.statement().is_some());
assert_eq!(
ctx.statement().unwrap(),
&StatementRef::builder()
.id("6690E6C93EF04ed38B377F3964730BEE")?
.build()?
);
assert!(ctx.context_activities().is_some());
assert!(ctx.context_agents().is_none());
assert!(ctx.context_groups().is_none());
let ca = ctx.context_activities().unwrap();
let p1 = Activity::builder()
.with_object_type() .id("http://www.example.com/meetings/series/267")?
.build()?;
assert_eq!(ca.parent(), [p1]);
let en = MyLanguageTag::from_str("en")?;
let c1 = Activity::builder()
.with_object_type() .id("http://www.example.com/meetings/categories/teammeeting")?
.definition(
ActivityDefinition::builder()
.name(&en, "team meeting")?
.description(&en, "A category of meeting used for regular team meetings.")?
.type_("http://example.com/expapi/activities/meetingcategory")?
.build()?,
)?
.build()?;
assert_eq!(ca.category(), &[c1]);
let o1 = Activity::builder()
.with_object_type()
.id("http://www.example.com/meetings/occurances/34257")?
.build()?;
let o2 = Activity::builder()
.with_object_type()
.id("http://www.example.com/meetings/occurances/3425567")?
.build()?;
assert_eq!(ca.other(), &[o1, o2]);
}
Ok(())
}
#[test_context(MyTestContext)]
#[traced_test]
#[test]
fn test_signed_statement(ctx: &mut MyTestContext) -> Result<(), MyError> {
fn att_signature(sig: &str) -> Vec<u8> {
let mut result = vec![];
result.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
result.extend_from_slice(b"Content-Transfer-Encoding: binary\r\n");
result.extend_from_slice(b"X-Experience-API-Hash: 672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634\r\n");
result.extend_from_slice(CR_LF);
result.extend_from_slice(sig.as_bytes());
result
}
let client = &ctx.client;
let (header, delimiter) = boundary_delimiter_line(BOUNDARY);
let stmt = read_to_string("statement-signed", true);
let sig = read_to_string("jws.sig", false);
let body = multipart(&delimiter, &stmt, Some(att_signature(&sig)), None);
let req = client
.post("/statements")
.body(body)
.header(content_type(&header))
.header(accept_json())
.header(v2())
.header(authorization());
let resp = req.dispatch();
let expected = if config().jws_strict {
Status::BadRequest
} else {
Status::Ok
};
assert_eq!(resp.status(), expected);
Ok(())
}
#[test_context(MyTestContext)]
#[traced_test]
#[test]
fn test_strict_signed_statement(ctx: &mut MyTestContext) -> Result<(), MyError> {
let c_str = read_to_string("C2.pem", false);
let c = X509::from_pem(&c_str.as_bytes())?;
let c_der = c.to_der()?;
let ca_str = read_to_string("C1.pem", false);
let ca = X509::from_pem(&ca_str.as_bytes())?;
let ca_der = ca.to_der()?;
let mut x5c = vec![];
x5c.push(c_der);
x5c.push(ca_der);
let mut header = JwsHeader::new();
header.set_algorithm("RS256");
header.set_x509_certificate_chain(&x5c);
let payload = read_to_string("statement-to-sign", true);
let signer_private_key = read_to_string("P2_private.pem", false);
let signer = RS256.signer_from_pem(&signer_private_key)?;
let compact_sig = jws::serialize_compact(payload.as_bytes(), &header, &signer)?;
let ct_hdr = format!("Content-Type: {}\r\n", SIGNATURE_CT);
let mut hasher = Sha256::new();
hasher.update(compact_sig.as_bytes());
let hash = hex::encode(hasher.finish());
let hash_hdr = format!("X-Experience-API-Hash: {}\r\n", hash);
let mut att_bytes = vec![];
att_bytes.extend_from_slice(ct_hdr.as_bytes());
att_bytes.extend_from_slice(b"Content-Transfer-Encoding: binary\r\n");
att_bytes.extend_from_slice(hash_hdr.as_bytes());
att_bytes.extend_from_slice(CR_LF);
att_bytes.extend_from_slice(compact_sig.as_bytes());
hasher = Sha256::new();
hasher.update(compact_sig.as_bytes());
let digest = hex::encode(hasher.finish());
let signature_att = Attachment::builder()
.usage_type(SIGNATURE_UT)?
.content_type(SIGNATURE_CT)?
.sha2(&digest)?
.length(
compact_sig
.len()
.try_into()
.expect("Failed coercing to i64"),
)?
.build()?;
let mut stmt = Statement::from_str(&payload)?;
let old_atts = stmt.attachments();
let new_atts = [old_atts, &[signature_att]].concat();
stmt.set_attachments(new_atts);
let stmt_json = serde_json::to_string(&stmt).expect("Failed serializing modified Statement");
let client = &ctx.client;
let (header, delimiter) = boundary_delimiter_line(BOUNDARY);
let body = multipart(&delimiter, &stmt_json, Some(att_bytes), None);
let req = client
.post("/statements")
.body(body)
.header(content_type(&header))
.header(accept_json())
.header(v2())
.header(authorization());
let resp = req.dispatch();
assert_eq!(resp.status(), Status::Ok);
Ok(())
}