blob: 84cc167f0cc5aa78898e7b267b7d94104b0c58bd [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use maka_agent_facts::configuration::onboarding::*;
use maka_agent_facts::configuration::*;
use maka_agent_facts::oauth::LoginStart;
use maka_agent_facts::oauth::Target;
use maka_agent_facts::provider::AuthenticationInput;
use maka_agent_facts::provider::Credential;
use maka_agent_facts::provider::Identity;
use maka_config::{
ConfigurationStore,
oauth::enrollment::LoginPreparation,
onboarding::{OnboardingPreparation, PreparedOnboarding},
};
use maka_event_log::root::{RootNamespaces, RootOwner};
use maka_plugin_core::scope::Scope;
use serde_json::json;
use sqlx::Connection;
use std::sync::Arc;
fn create() -> Target {
Target::Create {
provider: Identity {
package_id: "external.account".into(),
entry_id: "api".into(),
scope: Scope::Profile,
name: "api".into(),
},
configuration: json!({"baseUrl":"https://account.test/v1"}),
slug: "chosen-relay".into(),
name: "My relay".into(),
}
}
fn existing(row: &ConnectionCatalogEntry) -> Target {
Target::Existing {
expected: ConnectionCredentialTarget {
connection_id: row.connection_id.clone(),
revision: row.revision,
slug: row.slug.clone(),
provider: row.provider.clone(),
configuration: row.configuration.clone(),
},
configuration: row.configuration.clone(),
}
}
async fn prepare(store: &Arc<ConfigurationStore>, target: Target) -> Box<PreparedOnboarding> {
let OnboardingPreparation::Ready(ticket) = store.prepare_onboarding(target).await.unwrap()
else {
panic!("expected onboarding ticket")
};
ticket
}
#[tokio::test]
async fn onboarding_publishes_atomically_and_rejects_stale_configuration_or_authentication() {
let temp = tempfile::tempdir().unwrap();
let namespaces = RootNamespaces {
ownership: temp.path().join("owners"),
control: temp.path().join("control"),
};
let path = temp.path().join("root");
let store = Arc::new(
ConfigurationStore::for_root(Arc::new(RootOwner::create(&path, &namespaces).unwrap()))
.await
.unwrap(),
);
let ticket = prepare(&store, create()).await;
assert!(store.catalog().await.unwrap().connections.is_empty());
assert!(ticket.provider_credential().unwrap().is_none());
let mut sql = sqlx::SqliteConnection::connect_with(
&sqlx::sqlite::SqliteConnectOptions::new().filename(path.join("configuration-rust.sqlite")),
)
.await
.unwrap();
sqlx::query("CREATE TRIGGER fail_onboard BEFORE UPDATE ON connection_catalog BEGIN SELECT RAISE(ABORT, 'injected'); END")
.execute(&mut sql).await.unwrap();
assert!(
ticket
.complete(vec![ModelInfo::new("one")], vec![], 1)
.await
.is_err()
);
assert!(store.catalog().await.unwrap().connections.is_empty());
let revision: i64 = sqlx::query_scalar("SELECT revision FROM connection_catalog")
.fetch_one(&mut sql)
.await
.unwrap();
assert_eq!(revision, 0);
sqlx::query("DROP TRIGGER fail_onboard")
.execute(&mut sql)
.await
.unwrap();
let first = prepare(&store, create()).await;
let losing = prepare(&store, create()).await;
assert!(matches!(
first
.complete(
vec![ModelInfo::new("one"), ModelInfo::new("manual")],
vec![],
2,
)
.await
.unwrap(),
OnboardingSaveResult::Saved { .. }
));
assert_eq!(
losing
.complete(vec![ModelInfo::new("one")], vec![], 3)
.await
.unwrap(),
OnboardingSaveResult::Rejected {
reason: OnboardingRejection::SlugTaken
}
);
let catalog = store.catalog().await.unwrap();
assert_eq!(catalog.default_target.as_ref().unwrap().model_id, "one");
let row = &catalog.connections[0];
assert_eq!(row.name, "My relay");
let ticket = prepare(&store, existing(row)).await;
assert!(matches!(
ticket
.complete(vec![ModelInfo::new("one")], vec!["one".into()], 4)
.await
.unwrap(),
OnboardingSaveResult::Saved { .. }
));
let row = store.catalog().await.unwrap().connections.remove(0);
assert_eq!(row.enabled_model_ids, ["one", "manual"]);
let stale = prepare(&store, existing(&row)).await;
store
.update_connection(UpdateCatalogConnectionInput {
expected: ConnectionVersionBasis {
connection_id: row.connection_id.clone(),
revision: row.revision,
},
changes: ConnectionCatalogEntryUpdate {
name: Some("Concurrent rename".into()),
configuration: Some(row.configuration.clone()),
enabled: Some(true),
enabled_model_ids: Some(row.enabled_model_ids.clone()),
model_overrides: Patch::Keep,
request_body_overlay: Patch::Keep,
},
})
.await
.unwrap();
let before = store.catalog().await.unwrap();
assert_eq!(
stale
.complete(vec![ModelInfo::new("new")], vec![], 5)
.await
.unwrap(),
OnboardingSaveResult::Rejected {
reason: OnboardingRejection::Superseded
}
);
assert_eq!(store.catalog().await.unwrap(), before);
// Authentication is a separate accepted operation; discovery cannot overwrite it.
let target = existing(&before.connections[0]);
let stale = prepare(&store, target.clone()).await;
let LoginPreparation::Ready(login) = store
.prepare_oauth_login(LoginStart {
expected_credential: None,
attempt_id: "login".into(),
target,
authentication: AuthenticationInput {
method: "api-key".into(),
input: json!({"key":"input"}),
},
})
.await
.unwrap()
else {
panic!("login");
};
assert!(login.claim().await.unwrap());
login
.complete(
Credential {
secret: "accepted".into(),
refresh_at: None,
},
6,
)
.await
.unwrap();
assert_eq!(
stale
.complete(vec![ModelInfo::new("new")], vec![], 7)
.await
.unwrap(),
OnboardingSaveResult::Rejected {
reason: OnboardingRejection::Superseded
}
);
let before = store.catalog().await.unwrap();
let ticket = prepare(&store, existing(&before.connections[0])).await;
assert_eq!(
ticket
.provider_credential()
.unwrap()
.unwrap()
.credential()
.secret,
"accepted"
);
drop(ticket);
sql.close().await.unwrap();
store.shutdown().await.unwrap();
drop(store);
let store =
ConfigurationStore::for_root(Arc::new(RootOwner::open(&path, &namespaces).unwrap()))
.await
.unwrap();
assert_eq!(store.catalog().await.unwrap(), before);
store.close().await.unwrap();
}