Skip to content

Commit e3e10ad

Browse files
feat: validate tokens and repository access up front
Previously, an invalid or mis-scoped token only surfaced as a 401 from the upload endpoint after the full benchmark suite had run. The local provider also had to make two separate GraphQL calls — one to verify the token and one to look up the repository — without a clean error shape for the "missing repo / valid token" case. Combine session validation and repository lookup into a single `session_and_repository_overview` query, and use it for the local provider's resolution path: - `LocalProvider` now validates the token and the repository's `CREATE_LOCAL_RUN` access in one round-trip when resolving from a `-r` override or from a detected git remote. The project-repository fallback is unchanged and still relies on `get_or_create_project_repository` to surface auth errors. - `auth status` now renders the detected git remote alongside the authentication state, using the same combined query so we don't double-roundtrip when a remote is detected. - `auth login` validates the token via `session()` before persisting, so a malformed or expired token is rejected up front instead of being written to disk. - `REPOSITORY_NOT_FOUND` is folded into the success path (`repository_overview: None`) by deserializing the partial-data payload — relies on the gql_client partial-data fork. - `CurrentUser.gql` and `GetRepository.gql` are deleted; replaced by `Session.gql` and `SessionAndRepositoryOverview.gql`. Refs COD-2628 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ee4911e commit e3e10ad

6 files changed

Lines changed: 451 additions & 199 deletions

File tree

src/api_client.rs

Lines changed: 126 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ impl CodSpeedAPIClient {
3535
}
3636
}
3737

38+
/// Returns a client that uses `token` for authentication, regardless of
39+
/// the token this client was built with.
40+
pub fn with_token(&self, token: String) -> Self {
41+
Self::new(Some(token), self.api_url.clone())
42+
}
43+
3844
/// The token this client currently authenticates with, if any.
3945
///
4046
/// Note: this is not necessarily the token the client was built with —
@@ -302,47 +308,141 @@ nest! {
302308
}
303309
}
304310

305-
#[derive(Serialize, Clone)]
311+
nest! {
312+
#[derive(Debug, Deserialize, Serialize, Clone)]*
313+
#[serde(rename_all = "camelCase")]*
314+
pub struct SessionPayload {
315+
pub user: Option<pub struct SessionUser {
316+
pub login: String,
317+
pub provider: RepositoryProvider,
318+
}>,
319+
}
320+
}
321+
322+
#[derive(Debug, Deserialize, Serialize)]
306323
#[serde(rename_all = "camelCase")]
307-
pub struct GetRepositoryVars {
324+
struct SessionData {
325+
session: SessionPayload,
326+
}
327+
328+
/// Outcome of [`CodSpeedAPIClient::session`]. The CLI distinguishes
329+
/// "no/expired token" from any other error so it can render a clear message.
330+
pub enum SessionError {
331+
/// Token is missing or no longer valid.
332+
Unauthenticated,
333+
/// Anything else (network, server error, etc).
334+
Other(anyhow::Error),
335+
}
336+
337+
#[derive(Debug, Deserialize, Serialize, Clone)]
338+
#[serde(rename_all = "camelCase")]
339+
pub struct RepositoryOverviewPayload {
308340
pub owner: String,
309341
pub name: String,
310342
pub provider: RepositoryProvider,
343+
pub has_write_access: bool,
311344
}
312345

313-
nest! {
314-
#[derive(Debug, Deserialize, Serialize, Clone)]*
315-
#[serde(rename_all = "camelCase")]*
316-
struct GetRepositoryData {
317-
repository_overview: Option<pub struct GetRepositoryPayload {
318-
pub id: String,
319-
}>,
320-
user: Option<pub struct GetRepositoryUser {
321-
pub id: String,
322-
}>,
323-
}
346+
#[derive(Serialize, Clone)]
347+
#[serde(rename_all = "camelCase")]
348+
pub struct SessionAndRepositoryOverviewVars {
349+
pub owner: String,
350+
pub name: String,
351+
pub provider: Option<RepositoryProvider>,
324352
}
325353

326-
nest! {
327-
#[derive(Debug, Deserialize, Serialize)]*
328-
#[serde(rename_all = "camelCase")]*
329-
struct CurrentUserData {
330-
user: Option<pub struct CurrentUserPayload {
331-
pub login: String,
332-
pub provider: RepositoryProvider,
333-
}>,
334-
}
354+
#[derive(Debug, Deserialize, Serialize)]
355+
#[serde(rename_all = "camelCase")]
356+
struct SessionAndRepositoryOverviewData {
357+
session: SessionPayload,
358+
repository_overview: Option<RepositoryOverviewPayload>,
359+
}
360+
361+
pub struct SessionAndRepositoryOverview {
362+
pub session: SessionPayload,
363+
pub repository_overview: Option<RepositoryOverviewPayload>,
364+
}
365+
366+
/// Outcome of [`CodSpeedAPIClient::session_and_repository_overview`]. A
367+
/// missing repository is folded into the success path (`repository_overview`
368+
/// becomes `None`); only a missing/expired token or a transport-level
369+
/// failure surfaces here.
370+
pub enum SessionAndRepositoryOverviewError {
371+
Unauthenticated,
372+
Other(anyhow::Error),
335373
}
336374

337375
impl CodSpeedAPIClient {
338-
pub async fn get_current_user(&self) -> Result<Option<CurrentUserPayload>> {
376+
/// Introspect the token currently configured on this client.
377+
///
378+
/// Returns the linked user (when applicable). Used to verify a token's
379+
/// validity without conflating it with repository-level access checks —
380+
/// those are done with [`Self::session_and_repository_overview`].
381+
pub async fn session(&self) -> std::result::Result<SessionPayload, SessionError> {
339382
let response = self
340383
.gql_client
341-
.query_unwrap::<CurrentUserData>(include_str!("queries/CurrentUser.gql"))
384+
.query_unwrap::<SessionData>(include_str!("queries/Session.gql"))
342385
.await;
343386
match response {
344-
Ok(data) => Ok(data.user),
345-
Err(err) => bail!("Failed to get current user: {err}"),
387+
Ok(data) => Ok(data.session),
388+
Err(err) if err.contains_error_code("UNAUTHENTICATED") => {
389+
Err(SessionError::Unauthenticated)
390+
}
391+
Err(err) => Err(SessionError::Other(anyhow!(
392+
"Failed to validate token: {err}"
393+
))),
394+
}
395+
}
396+
397+
/// Validate the token and look up a candidate repository in one
398+
/// round-trip. Used by `auth status` (with a detected git remote) and
399+
/// the up-front check in `run`/`exec`.
400+
///
401+
/// `repositoryOverview` is nullable in the schema, so a missing
402+
/// repository surfaces as `repository_overview: None` on the success
403+
/// path. The server still returns a `REPOSITORY_NOT_FOUND` error in
404+
/// that case to avoid leaking existence info, but the partial-data
405+
/// payload carries the `session` field — we deserialize it from the
406+
/// error and treat the call as successful for the session's purposes.
407+
pub async fn session_and_repository_overview(
408+
&self,
409+
vars: SessionAndRepositoryOverviewVars,
410+
) -> std::result::Result<SessionAndRepositoryOverview, SessionAndRepositoryOverviewError> {
411+
let response = self
412+
.gql_client
413+
.query_with_vars_unwrap::<
414+
SessionAndRepositoryOverviewData,
415+
SessionAndRepositoryOverviewVars,
416+
>(
417+
include_str!("queries/SessionAndRepositoryOverview.gql"),
418+
vars,
419+
)
420+
.await;
421+
match response {
422+
Ok(data) => Ok(SessionAndRepositoryOverview {
423+
session: data.session,
424+
repository_overview: data.repository_overview,
425+
}),
426+
Err(err) if err.contains_error_code("UNAUTHENTICATED") => {
427+
Err(SessionAndRepositoryOverviewError::Unauthenticated)
428+
}
429+
Err(err) if err.contains_error_code("REPOSITORY_NOT_FOUND") => {
430+
match err.data::<SessionAndRepositoryOverviewData>() {
431+
Some(Ok(data)) => Ok(SessionAndRepositoryOverview {
432+
session: data.session,
433+
repository_overview: None,
434+
}),
435+
Some(Err(decode_err)) => Err(SessionAndRepositoryOverviewError::Other(
436+
anyhow!("Failed to deserialize partial response data: {decode_err}"),
437+
)),
438+
None => Err(SessionAndRepositoryOverviewError::Other(anyhow!(
439+
"Server returned REPOSITORY_NOT_FOUND without partial data: {err}"
440+
))),
441+
}
442+
}
443+
Err(err) => Err(SessionAndRepositoryOverviewError::Other(anyhow!(
444+
"Failed to validate token and repository: {err}"
445+
))),
346446
}
347447
}
348448

@@ -442,38 +542,6 @@ impl CodSpeedAPIClient {
442542
Err(err) => bail!("Failed to get or create project repository: {err}"),
443543
}
444544
}
445-
446-
/// Check if a repository exists in CodSpeed.
447-
/// Returns Some(payload) if the repository exists, None otherwise.
448-
pub async fn get_repository(
449-
&self,
450-
vars: GetRepositoryVars,
451-
) -> Result<Option<GetRepositoryPayload>> {
452-
let response = self
453-
.gql_client
454-
.query_with_vars_unwrap::<GetRepositoryData, GetRepositoryVars>(
455-
include_str!("queries/GetRepository.gql"),
456-
vars.clone(),
457-
)
458-
.await;
459-
match response {
460-
Ok(response) => {
461-
if response.user.is_none() {
462-
bail!(
463-
"Your session has expired, please login again using `codspeed auth login`"
464-
);
465-
}
466-
Ok(response.repository_overview)
467-
}
468-
Err(err) if err.contains_error_code("REPOSITORY_NOT_FOUND") => Ok(None),
469-
Err(err) if err.contains_error_code("UNAUTHENTICATED") => {
470-
bail!("Your session has expired, please login again using `codspeed auth login`")
471-
}
472-
Err(err) => {
473-
bail!("Failed to get repository: {err}")
474-
}
475-
}
476-
}
477545
}
478546

479547
impl CodSpeedAPIClient {

0 commit comments

Comments
 (0)