Skip to content

Commit dc9801d

Browse files
committed
chore: add setup e2e harness for auth and agent selection flows
1 parent b4ccc25 commit dc9801d

3 files changed

Lines changed: 802 additions & 80 deletions

File tree

src/auth.rs

Lines changed: 239 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ pub struct ProfileInfo {
6161
pub api_key_hint: Option<String>,
6262
}
6363

64+
#[derive(Debug, Clone)]
65+
pub(crate) struct StoredProfileInfo {
66+
pub name: String,
67+
pub is_oauth: bool,
68+
pub org_name: Option<String>,
69+
}
70+
6471
#[derive(Debug, Clone, PartialEq, Eq)]
6572
pub struct AvailableOrg {
6673
pub id: String,
@@ -125,6 +132,19 @@ pub fn list_profiles() -> Result<Vec<ProfileInfo>> {
125132
.collect())
126133
}
127134

135+
pub(crate) fn list_stored_profiles() -> Result<Vec<StoredProfileInfo>> {
136+
let store = load_auth_store()?;
137+
Ok(store
138+
.profiles
139+
.iter()
140+
.map(|(name, profile)| StoredProfileInfo {
141+
name: name.clone(),
142+
is_oauth: profile.auth_kind == AuthKind::Oauth,
143+
org_name: profile.org_name.clone(),
144+
})
145+
.collect())
146+
}
147+
128148
pub fn resolve_org_to_profile(identifier: &str, profiles: &[ProfileInfo]) -> Result<String> {
129149
if profiles.is_empty() {
130150
bail!("no auth profiles found. Run `bt auth login` to create one.");
@@ -233,6 +253,28 @@ pub async fn list_available_orgs(base: &BaseArgs) -> Result<Vec<AvailableOrg>> {
233253
.collect())
234254
}
235255

256+
pub(crate) async fn list_available_orgs_for_api_key(
257+
api_key: &str,
258+
app_url: &str,
259+
) -> Result<Vec<AvailableOrg>> {
260+
let mut orgs = fetch_login_orgs(api_key, app_url).await?;
261+
orgs.sort_by(|a, b| {
262+
a.name
263+
.to_ascii_lowercase()
264+
.cmp(&b.name.to_ascii_lowercase())
265+
.then_with(|| a.name.cmp(&b.name))
266+
});
267+
268+
Ok(orgs
269+
.into_iter()
270+
.map(|org| AvailableOrg {
271+
id: org.id,
272+
name: org.name,
273+
api_url: org.api_url,
274+
})
275+
.collect())
276+
}
277+
236278
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
237279
struct AuthStore {
238280
#[serde(default)]
@@ -762,11 +804,16 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {
762804
)?;
763805
let selected_api_url =
764806
resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?;
765-
let profile_name = resolve_profile_name(
807+
let store = load_auth_store()?;
808+
let (profile_name, should_confirm_overwrite) = resolve_api_key_login_profile_name(
766809
base.profile.as_deref(),
767810
selected_org.as_ref().map(|org| org.name.as_str()),
811+
&selected_api_url,
812+
&store,
768813
)?;
769-
confirm_profile_overwrite(&profile_name)?;
814+
if should_confirm_overwrite {
815+
confirm_profile_overwrite(&profile_name)?;
816+
}
770817

771818
commit_api_key_profile(
772819
&profile_name,
@@ -862,11 +909,19 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> {
862909
)?;
863910
let selected_api_url =
864911
resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?;
865-
let profile_name = resolve_profile_name(
912+
let store = load_auth_store()?;
913+
let jwt_id = decode_jwt_identity(&oauth_tokens.access_token);
914+
let (profile_name, should_confirm_overwrite) = resolve_oauth_login_profile_name(
866915
base.profile.as_deref(),
867916
selected_org.as_ref().map(|org| org.name.as_str()),
917+
&selected_api_url,
918+
&app_url,
919+
&jwt_id,
920+
&store,
868921
)?;
869-
confirm_profile_overwrite(&profile_name)?;
922+
if should_confirm_overwrite {
923+
confirm_profile_overwrite(&profile_name)?;
924+
}
870925

871926
commit_oauth_profile(
872927
&profile_name,
@@ -960,11 +1015,19 @@ pub(crate) async fn login_interactive_oauth(base: &mut BaseArgs) -> Result<Strin
9601015
)?;
9611016
let selected_api_url =
9621017
resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?;
963-
let profile_name = resolve_profile_name(
1018+
let store = load_auth_store()?;
1019+
let jwt_id = decode_jwt_identity(&oauth_tokens.access_token);
1020+
let (profile_name, should_confirm_overwrite) = resolve_oauth_login_profile_name(
9641021
base.profile.as_deref(),
9651022
selected_org.as_ref().map(|org| org.name.as_str()),
1023+
&selected_api_url,
1024+
&app_url,
1025+
&jwt_id,
1026+
&store,
9661027
)?;
967-
confirm_profile_overwrite(&profile_name)?;
1028+
if should_confirm_overwrite {
1029+
confirm_profile_overwrite(&profile_name)?;
1030+
}
9681031

9691032
commit_oauth_profile(
9701033
&profile_name,
@@ -979,7 +1042,7 @@ pub(crate) async fn login_interactive_oauth(base: &mut BaseArgs) -> Result<Strin
9791042
Ok(profile_name)
9801043
}
9811044

982-
fn commit_api_key_profile(
1045+
pub(crate) fn commit_api_key_profile(
9831046
profile_name: &str,
9841047
api_key: &str,
9851048
api_url: String,
@@ -1172,6 +1235,102 @@ fn resolve_profile_name(
11721235
.to_string())
11731236
}
11741237

1238+
fn default_profile_name(suggested_org_name: Option<&str>) -> String {
1239+
suggested_org_name
1240+
.map(str::trim)
1241+
.filter(|name| !name.is_empty())
1242+
.unwrap_or("profile")
1243+
.to_string()
1244+
}
1245+
1246+
fn next_available_profile_name(base_name: &str, store: &AuthStore) -> String {
1247+
if !store.profiles.contains_key(base_name) {
1248+
return base_name.to_string();
1249+
}
1250+
1251+
(2u32..)
1252+
.map(|idx| format!("{base_name}-{idx}"))
1253+
.find(|candidate| !store.profiles.contains_key(candidate))
1254+
.expect("profile name sequence is infinite")
1255+
}
1256+
1257+
fn resolve_api_key_login_profile_name(
1258+
explicit_profile: Option<&str>,
1259+
suggested_org_name: Option<&str>,
1260+
selected_api_url: &str,
1261+
store: &AuthStore,
1262+
) -> Result<(String, bool)> {
1263+
if let Some(profile_name) = explicit_profile {
1264+
let profile_name = resolve_profile_name(Some(profile_name), suggested_org_name)?;
1265+
return Ok((
1266+
profile_name.clone(),
1267+
store.profiles.contains_key(&profile_name),
1268+
));
1269+
}
1270+
1271+
let default_name = default_profile_name(suggested_org_name);
1272+
let has_matching_api_key_profile = store.profiles.values().any(|profile| {
1273+
profile.auth_kind == AuthKind::ApiKey
1274+
&& profile.api_url.as_deref() == Some(selected_api_url)
1275+
&& profile.org_name.as_deref() == suggested_org_name
1276+
});
1277+
1278+
if has_matching_api_key_profile {
1279+
return Ok((next_available_profile_name(&default_name, store), false));
1280+
}
1281+
1282+
Ok((
1283+
default_name.clone(),
1284+
store.profiles.contains_key(&default_name),
1285+
))
1286+
}
1287+
1288+
fn resolve_oauth_login_profile_name(
1289+
explicit_profile: Option<&str>,
1290+
suggested_org_name: Option<&str>,
1291+
selected_api_url: &str,
1292+
app_url: &str,
1293+
jwt_id: &JwtIdentity,
1294+
store: &AuthStore,
1295+
) -> Result<(String, bool)> {
1296+
if let Some(profile_name) = explicit_profile {
1297+
let profile_name = resolve_profile_name(Some(profile_name), suggested_org_name)?;
1298+
return Ok((
1299+
profile_name.clone(),
1300+
store.profiles.contains_key(&profile_name),
1301+
));
1302+
}
1303+
1304+
let matched_profile = store
1305+
.profiles
1306+
.iter()
1307+
.filter(|(_, profile)| {
1308+
profile.auth_kind == AuthKind::Oauth
1309+
&& profile.api_url.as_deref() == Some(selected_api_url)
1310+
&& profile.app_url.as_deref() == Some(app_url)
1311+
&& profile.org_name.as_deref() == suggested_org_name
1312+
&& profile.user_name == jwt_id.name
1313+
&& profile.email == jwt_id.email
1314+
})
1315+
.max_by(|(left_name, left), (right_name, right)| {
1316+
left.oauth_access_expires_at
1317+
.unwrap_or_default()
1318+
.cmp(&right.oauth_access_expires_at.unwrap_or_default())
1319+
.then_with(|| left_name.cmp(right_name))
1320+
})
1321+
.map(|(name, _)| name.clone());
1322+
1323+
if let Some(profile_name) = matched_profile {
1324+
return Ok((profile_name, false));
1325+
}
1326+
1327+
let default_name = default_profile_name(suggested_org_name);
1328+
Ok((
1329+
default_name.clone(),
1330+
store.profiles.contains_key(&default_name),
1331+
))
1332+
}
1333+
11751334
fn confirm_profile_overwrite(profile_name: &str) -> Result<()> {
11761335
let store = load_auth_store()?;
11771336
if !store.profiles.contains_key(profile_name) {
@@ -3197,6 +3356,79 @@ mod tests {
31973356
assert_eq!(resolved.org_name.as_deref(), Some("acme-corp"));
31983357
}
31993358

3359+
#[test]
3360+
fn resolve_api_key_login_profile_name_creates_new_profile_for_matching_org() {
3361+
let mut store = AuthStore::default();
3362+
store.profiles.insert(
3363+
"acme".into(),
3364+
AuthProfile {
3365+
auth_kind: AuthKind::ApiKey,
3366+
api_url: Some("https://api.acme.example".into()),
3367+
org_name: Some("acme".into()),
3368+
..Default::default()
3369+
},
3370+
);
3371+
3372+
let (profile_name, should_confirm) = resolve_api_key_login_profile_name(
3373+
None,
3374+
Some("acme"),
3375+
"https://api.acme.example",
3376+
&store,
3377+
)
3378+
.expect("resolve");
3379+
3380+
assert_eq!(profile_name, "acme-2");
3381+
assert!(!should_confirm);
3382+
}
3383+
3384+
#[test]
3385+
fn resolve_oauth_login_profile_name_reuses_most_recent_matching_profile() {
3386+
let mut store = AuthStore::default();
3387+
store.profiles.insert(
3388+
"older".into(),
3389+
AuthProfile {
3390+
auth_kind: AuthKind::Oauth,
3391+
api_url: Some("https://api.acme.example".into()),
3392+
app_url: Some("https://www.acme.example".into()),
3393+
org_name: Some("acme".into()),
3394+
oauth_access_expires_at: Some(100),
3395+
user_name: Some("Alice".into()),
3396+
email: Some("alice@example.com".into()),
3397+
..Default::default()
3398+
},
3399+
);
3400+
store.profiles.insert(
3401+
"newer".into(),
3402+
AuthProfile {
3403+
auth_kind: AuthKind::Oauth,
3404+
api_url: Some("https://api.acme.example".into()),
3405+
app_url: Some("https://www.acme.example".into()),
3406+
org_name: Some("acme".into()),
3407+
oauth_access_expires_at: Some(200),
3408+
user_name: Some("Alice".into()),
3409+
email: Some("alice@example.com".into()),
3410+
..Default::default()
3411+
},
3412+
);
3413+
3414+
let jwt_id = JwtIdentity {
3415+
name: Some("Alice".into()),
3416+
email: Some("alice@example.com".into()),
3417+
};
3418+
let (profile_name, should_confirm) = resolve_oauth_login_profile_name(
3419+
None,
3420+
Some("acme"),
3421+
"https://api.acme.example",
3422+
"https://www.acme.example",
3423+
&jwt_id,
3424+
&store,
3425+
)
3426+
.expect("resolve");
3427+
3428+
assert_eq!(profile_name, "newer");
3429+
assert!(!should_confirm);
3430+
}
3431+
32003432
#[test]
32013433
fn obscure_api_key_standard() {
32023434
assert_eq!(obscure_api_key("sk-LumEdp0BbLRzhJwO"), "sk-****zhJwO");

0 commit comments

Comments
 (0)