swarm UI: fetch linked accounts only for the opened agent
The detail panel makes one request for the agent it shows,
GET /api/hives/{hive}/agents/{agent}/linked-accounts, which returns every
matrix, forge and github account of that agent as names and hosts. The
all-agents route and the table's matrix-column rows are removed, so the
table makes no linked-accounts request. The panel stays keyed by
hive/agent. The bao grant is unchanged.
Refs #4855
This commit is contained in:
parent
7ccde4647b
commit
9224c0bd15
5 changed files with 78 additions and 195 deletions
|
|
@ -26,10 +26,9 @@ into the swarm secret store through swarm-controller. All three are blind
|
||||||
set/update actions: no route hands a token back.
|
set/update actions: no route hands a token back.
|
||||||
|
|
||||||
The agent's detail panel lists its linked accounts under **accounts**, one row
|
The agent's detail panel lists its linked accounts under **accounts**, one row
|
||||||
per account: kind, name and host. The table view's matrix column lists the
|
per account: kind, name and host. Opening an agent makes one request,
|
||||||
matrix rows. The page fills both from one request,
|
`GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which returns every
|
||||||
`GET /api/agents/linked-accounts`, which returns every agent's accounts as
|
account of that agent as names and hosts and never a credential.
|
||||||
names and hosts, grouped by hive and agent, and never a credential.
|
|
||||||
|
|
||||||
| kind | one row per | name | host |
|
| kind | one row per | name | host |
|
||||||
| ------- | ------------------------------------------- | ----------- | ------------------------ |
|
| ------- | ------------------------------------------- | ----------- | ------------------------ |
|
||||||
|
|
@ -38,8 +37,8 @@ names and hosts, grouped by hive and agent, and never a credential.
|
||||||
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` |
|
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` |
|
||||||
|
|
||||||
The matrix `main` row is the agent's own account, which the swarm mints;
|
The matrix `main` row is the agent's own account, which the swarm mints;
|
||||||
it carries an **own account** badge. The lists refresh when a link dialog
|
it carries an **own account** badge. The panel requests the list again when
|
||||||
closes.
|
a link dialog closes.
|
||||||
|
|
||||||
- **link a matrix account** — `PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
- **link a matrix account** — `PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
||||||
either a pasted bearer token or a user id + password that
|
either a pasted bearer token or a user id + password that
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import { LinkIcon } from "@hive/shared/icons.js";
|
||||||
import { AgentCard } from "./AgentCard.js";
|
import { AgentCard } from "./AgentCard.js";
|
||||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||||
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||||
import { LinkedAccounts, useLinkedAccounts } from "./LinkedAccounts.js";
|
import { LinkedAccounts } from "./LinkedAccounts.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||||
|
|
@ -159,10 +159,9 @@ export function AgentsPage() {
|
||||||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||||
// The row showing the "link a forge account" dialog, same shape.
|
// The row showing the "link a forge account" dialog, same shape.
|
||||||
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
||||||
// Bumped whenever a link dialog closes; `useLinkedAccounts` refetches
|
// Bumped whenever a link dialog closes; the panel's `LinkedAccounts` refetches
|
||||||
// on a change, so a newly linked account shows without a reload.
|
// on a change, so a newly linked account shows without a reload.
|
||||||
const [linkVersion, setLinkVersion] = useState(0);
|
const [linkVersion, setLinkVersion] = useState(0);
|
||||||
const linked = useLinkedAccounts(linkVersion);
|
|
||||||
const linkClosed = () => setLinkVersion((v) => v + 1);
|
const linkClosed = () => setLinkVersion((v) => v + 1);
|
||||||
const closeMatrix = () => {
|
const closeMatrix = () => {
|
||||||
setMatrixTarget(null);
|
setMatrixTarget(null);
|
||||||
|
|
@ -392,28 +391,18 @@ export function AgentsPage() {
|
||||||
key: "matrix",
|
key: "matrix",
|
||||||
header: "matrix",
|
header: "matrix",
|
||||||
render: (a) => (
|
render: (a) => (
|
||||||
<>
|
<Badge
|
||||||
{a.hive ? (
|
variant="quiet"
|
||||||
<LinkedAccounts
|
icon={<LinkIcon />}
|
||||||
key={`${a.hive}/${a.name}`}
|
value="link account"
|
||||||
state={linked}
|
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
|
||||||
agent={a.name}
|
disabled={!a.hive}
|
||||||
kinds={["matrix"]}
|
title={
|
||||||
/>
|
a.hive
|
||||||
) : null}
|
? `link a matrix account to ${a.name}`
|
||||||
<Badge
|
: "no hive on record for this agent — nothing to link against"
|
||||||
variant="quiet"
|
}
|
||||||
icon={<LinkIcon />}
|
/>
|
||||||
value="link account"
|
|
||||||
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
|
|
||||||
disabled={!a.hive}
|
|
||||||
title={
|
|
||||||
a.hive
|
|
||||||
? `link a matrix account to ${a.name}`
|
|
||||||
: "no hive on record for this agent — nothing to link against"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -602,8 +591,9 @@ export function AgentsPage() {
|
||||||
{detailTarget.hive ? (
|
{detailTarget.hive ? (
|
||||||
<LinkedAccounts
|
<LinkedAccounts
|
||||||
key={`${detailTarget.hive}/${detailTarget.name}`}
|
key={`${detailTarget.hive}/${detailTarget.name}`}
|
||||||
state={linked}
|
hive={detailTarget.hive}
|
||||||
agent={detailTarget.name}
|
agent={detailTarget.name}
|
||||||
|
version={linkVersion}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,78 @@
|
||||||
// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
|
// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
|
||||||
// name, host. Renders its slice of `GET /api/agents/linked-accounts`, which
|
// name, host. Reads `GET /api/hives/{hive}/agents/{agent}/linked-accounts`,
|
||||||
// `AgentsPage` fetches once for every agent (`useLinkedAccounts`) and which
|
// which carries names and hosts only, never a credential.
|
||||||
// carries names and hosts only, never a credential.
|
|
||||||
//
|
//
|
||||||
// Callers key it by hive and agent, so switching agents remounts it instead
|
// Fetched on mount and again whenever `version` changes; `AgentsPage` bumps
|
||||||
// of reusing the previous agent's element.
|
// it when a link dialog closes, so an account linked there shows up without
|
||||||
|
// waiting for a reload.
|
||||||
|
//
|
||||||
|
// Its state belongs to one agent: callers key it by hive and agent, so
|
||||||
|
// switching agents remounts it instead of showing the previous agent's rows.
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Badge } from "@hive/shared/badge.js";
|
import { Badge } from "@hive/shared/badge.js";
|
||||||
import "./LinkedAccounts.css";
|
import "./LinkedAccounts.css";
|
||||||
|
|
||||||
export type AccountKind = "matrix" | "forgejo" | "github";
|
type AccountKind = "matrix" | "forgejo" | "github";
|
||||||
|
|
||||||
// Mirrors `linked_accounts::LinkedAccount`.
|
// Mirrors `linked_accounts::LinkedAccount`.
|
||||||
export interface LinkedAccount {
|
interface LinkedAccount {
|
||||||
kind: AccountKind;
|
kind: AccountKind;
|
||||||
name: string;
|
name: string;
|
||||||
host: string | null;
|
host: string | null;
|
||||||
reserved: boolean;
|
reserved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors `linked_accounts::AgentLinkedAccounts`.
|
export function LinkedAccounts({
|
||||||
interface AgentLinkedAccounts {
|
hive,
|
||||||
hive: string | null;
|
agent,
|
||||||
|
version,
|
||||||
|
}: {
|
||||||
|
hive: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
accounts: LinkedAccount[];
|
version: number;
|
||||||
}
|
}) {
|
||||||
|
const [accounts, setAccounts] = useState<LinkedAccount[] | null>(null);
|
||||||
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
||||||
// What the page holds: each agent's accounts by agent name (the roster's
|
|
||||||
// row key), or the error the one fetch failed with.
|
|
||||||
export type LinkedAccountsState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; byAgent: ReadonlyMap<string, LinkedAccount[]> }
|
|
||||||
| { status: "error"; problem: ProblemDetails };
|
|
||||||
|
|
||||||
// The one fetch, re-run whenever `version` changes; `AgentsPage` bumps it
|
|
||||||
// when a link dialog closes, so an account linked there shows up without a
|
|
||||||
// reload. Keeps the previous result on screen while a refetch is in flight.
|
|
||||||
export function useLinkedAccounts(version: number): LinkedAccountsState {
|
|
||||||
const [state, setState] = useState<LinkedAccountsState>({
|
|
||||||
status: "loading",
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const r = await fetch("/api/agents/linked-accounts");
|
const r = await fetch(
|
||||||
|
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
|
||||||
|
);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const problem = await readApiError(r);
|
if (!cancelled) setError(await readApiError(r));
|
||||||
if (!cancelled) setState({ status: "error", problem });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await r.json()) as AgentLinkedAccounts[];
|
const data = (await r.json()) as LinkedAccount[];
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setState({
|
setAccounts(data);
|
||||||
status: "loaded",
|
setError(null);
|
||||||
byAgent: new Map(data.map((e) => [e.agent, e.accounts])),
|
|
||||||
});
|
|
||||||
})().catch((e: unknown) => {
|
})().catch((e: unknown) => {
|
||||||
if (!cancelled)
|
if (!cancelled) setError({ detail: String(e) });
|
||||||
setState({ status: "error", problem: { detail: String(e) } });
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [version]);
|
}, [hive, agent, version]);
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LinkedAccounts({
|
if (error) {
|
||||||
state,
|
|
||||||
agent,
|
|
||||||
kinds,
|
|
||||||
}: {
|
|
||||||
state: LinkedAccountsState;
|
|
||||||
agent: string;
|
|
||||||
/** Only these kinds; all of them when omitted. */
|
|
||||||
kinds?: AccountKind[];
|
|
||||||
}) {
|
|
||||||
if (state.status === "error") {
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
tone="negative"
|
tone="negative"
|
||||||
value="accounts unavailable"
|
value="accounts unavailable"
|
||||||
title={state.problem.detail ?? "listing linked accounts failed"}
|
title={error.detail ?? "listing linked accounts failed"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state.status === "loading") {
|
if (accounts === null) return <span class="ui-linked-accounts-muted">…</span>;
|
||||||
return <span class="ui-linked-accounts-muted">…</span>;
|
if (accounts.length === 0) {
|
||||||
}
|
|
||||||
const accounts = state.byAgent.get(agent) ?? [];
|
|
||||||
const shown = kinds
|
|
||||||
? accounts.filter((a) => kinds.includes(a.kind))
|
|
||||||
: accounts;
|
|
||||||
if (shown.length === 0) {
|
|
||||||
return <span class="ui-linked-accounts-muted">none linked</span>;
|
return <span class="ui-linked-accounts-muted">none linked</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul class="ui-linked-accounts">
|
<ul class="ui-linked-accounts">
|
||||||
{shown.map((a) => (
|
{accounts.map((a) => (
|
||||||
<li key={`${a.kind}/${a.name}`}>
|
<li key={`${a.kind}/${a.name}`}>
|
||||||
<Badge label={a.kind} value={a.name} />
|
<Badge label={a.kind} value={a.name} />
|
||||||
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,8 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
||||||
GitHub account for one agent, stored in the swarm secret store
|
GitHub account for one agent, stored in the swarm secret store
|
||||||
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
||||||
`.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted
|
`.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted
|
||||||
accounts above. `GET /api/agents/linked-accounts` lists them for every agent in one
|
accounts above. `GET .../linked-accounts` lists them, plus the agent's own
|
||||||
response, plus each agent's own `main` matrix account, by kind, name and
|
`main` matrix account, by kind, name and host, never with a credential.
|
||||||
host, never with a credential.
|
|
||||||
- **Config PR status** — each agent's open config-repo PR, cached from forge
|
- **Config PR status** — each agent's open config-repo PR, cached from forge
|
||||||
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`).
|
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`).
|
||||||
- **Swarm-wide forge objects and webhooks** →
|
- **Swarm-wide forge objects and webhooks** →
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! The accounts linked to each agent, as kind, name and host: what the swarm
|
//! The accounts linked to one agent, as kind, name and host: what the swarm
|
||||||
//! UI's agents page lists, served for every agent in one response.
|
//! UI's agent panel lists.
|
||||||
//!
|
//!
|
||||||
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and
|
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and
|
||||||
//! [`crate::github_account`] write, plus the agent's own `main` matrix account,
|
//! [`crate::github_account`] write, plus the agent's own `main` matrix account,
|
||||||
|
|
@ -21,7 +21,7 @@ use serde::de::DeserializeOwned;
|
||||||
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
|
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use super::{AppState, error_problem};
|
use super::{AppState, error_problem, swarm_hive};
|
||||||
|
|
||||||
/// The host shown for a GitHub token. The store keeps none: a token is only
|
/// The host shown for a GitHub token. The store keeps none: a token is only
|
||||||
/// ever used against github.com.
|
/// ever used against github.com.
|
||||||
|
|
@ -141,78 +141,36 @@ pub(crate) async fn linked_accounts(
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One agent's linked accounts, beside the hive it reported from.
|
/// List the accounts linked to an agent: names and hosts, never a credential.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
|
||||||
pub struct AgentLinkedAccounts {
|
|
||||||
/// `None` for an agent that has never reported, as in `/api/agents/status`.
|
|
||||||
hive: Option<String>,
|
|
||||||
agent: String,
|
|
||||||
accounts: Vec<LinkedAccount>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`linked_accounts`] for each `(hive, agent)`, in the order given.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// The first error [`linked_accounts`] returns for any agent.
|
|
||||||
pub(crate) async fn every_agent(
|
|
||||||
store: &impl AccountStore,
|
|
||||||
agents: Vec<(Option<String>, String)>,
|
|
||||||
) -> Result<Vec<AgentLinkedAccounts>, Error> {
|
|
||||||
let mut out = Vec::with_capacity(agents.len());
|
|
||||||
for (hive, agent) in agents {
|
|
||||||
let accounts = linked_accounts(store, &agent).await?;
|
|
||||||
out.push(AgentLinkedAccounts {
|
|
||||||
hive,
|
|
||||||
agent,
|
|
||||||
accounts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The accounts linked to every agent, one entry per agent `/api/agents/status`
|
|
||||||
/// returns a row for: names and hosts, never a credential.
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/agents/linked-accounts",
|
path = "/api/hives/{hive}/agents/{agent}/linked-accounts",
|
||||||
|
params(
|
||||||
|
("hive" = String, Path, description = "hive the agent runs on"),
|
||||||
|
("agent" = String, Path, description = "agent whose accounts to list"),
|
||||||
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "one entry per agent, its accounts empty when it has none", body = Vec<AgentLinkedAccounts>),
|
(status = 200, description = "the agent's linked accounts, empty when it has none", body = Vec<LinkedAccount>),
|
||||||
(status = 500, description = "the secret store could not be read (problem+json)", body = String),
|
(status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
|
||||||
(status = 503, description = "no swarm queue or identity bridge is configured here, or either could not be read (problem+json)", body = String),
|
(status = 500, description = "the store could not be read (problem+json)", body = String),
|
||||||
),
|
),
|
||||||
tag = "agents"
|
tag = "agents"
|
||||||
)]
|
)]
|
||||||
pub async fn get_linked_accounts(
|
pub async fn get_linked_accounts(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<AgentLinkedAccounts>>, problem_details::ProblemDetails> {
|
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
||||||
let unavailable = |detail: &str| error_problem(StatusCode::SERVICE_UNAVAILABLE, detail);
|
) -> Result<Json<Vec<LinkedAccount>>, problem_details::ProblemDetails> {
|
||||||
let Some(reader) = state.agent_status.as_ref() else {
|
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
||||||
return Err(unavailable("no swarm queue is configured on this host"));
|
let agent = hive_types::Ident::parse(&agent)
|
||||||
};
|
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||||
let Some(bridge) = state.auth.as_ref() else {
|
.into_string();
|
||||||
return Err(unavailable("no identity bridge is configured on this host"));
|
|
||||||
};
|
|
||||||
let roster = bridge.list_agent_identities().await.map_err(|e| {
|
|
||||||
let detail = format!("{e:#}");
|
|
||||||
tracing::warn!(error = %detail, "reading the agent roster failed");
|
|
||||||
unavailable(&detail)
|
|
||||||
})?;
|
|
||||||
let rows = reader
|
|
||||||
.view(&roster, std::time::SystemTime::now())
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
let detail = format!("{e:#}");
|
|
||||||
tracing::warn!(error = %detail, "reading the agent-status bucket failed");
|
|
||||||
unavailable(&detail)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let store = crate::store::connect().await.map_err(|e| {
|
let store = crate::store::connect().await.map_err(|e| {
|
||||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||||
})?;
|
})?;
|
||||||
let agents = rows.into_iter().map(|r| (r.hive, r.name)).collect();
|
let accounts = linked_accounts(&store, &agent).await.map_err(|e| {
|
||||||
let accounts = every_agent(&store, agents).await.map_err(|e| {
|
tracing::warn!(%hive, %agent, error = %e, "listing linked accounts failed");
|
||||||
tracing::warn!(error = %e, "listing linked accounts failed");
|
|
||||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(accounts))
|
Ok(Json(accounts))
|
||||||
|
|
@ -226,7 +184,7 @@ mod tests {
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use swarm_secret_client::Error;
|
use swarm_secret_client::Error;
|
||||||
|
|
||||||
use super::{AccountKind, AccountStore, LinkedAccount, every_agent, linked_accounts};
|
use super::{AccountKind, AccountStore, LinkedAccount, linked_accounts};
|
||||||
|
|
||||||
/// Objects by path. A list answers the next segment of every path under
|
/// Objects by path. A list answers the next segment of every path under
|
||||||
/// the directory, with a trailing `/` when it goes deeper, as the store
|
/// the directory, with a trailing `/` when it goes deeper, as the store
|
||||||
|
|
@ -376,43 +334,6 @@ mod tests {
|
||||||
assert!(text.contains("catgirl"), "{text}");
|
assert!(text.contains("catgirl"), "{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn every_agent_is_one_entry_per_hive_and_agent_with_no_credential() {
|
|
||||||
let got = every_agent(
|
|
||||||
&every_kind(),
|
|
||||||
vec![
|
|
||||||
(Some("pr1ma".to_owned()), "atlas".to_owned()),
|
|
||||||
(Some("pr1ma".to_owned()), "red".to_owned()),
|
|
||||||
(None, "quiet".to_owned()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("store answers");
|
|
||||||
let body = serde_json::to_value(&got).expect("serialises");
|
|
||||||
let entries = body.as_array().expect("an array");
|
|
||||||
assert_eq!(entries.len(), 3, "{body}");
|
|
||||||
for entry in entries {
|
|
||||||
let mut keys: Vec<&str> = entry
|
|
||||||
.as_object()
|
|
||||||
.expect("an object")
|
|
||||||
.keys()
|
|
||||||
.map(String::as_str)
|
|
||||||
.collect();
|
|
||||||
keys.sort_unstable();
|
|
||||||
assert_eq!(keys, ["accounts", "agent", "hive"], "{entry}");
|
|
||||||
}
|
|
||||||
assert_eq!(body[0]["hive"], "pr1ma");
|
|
||||||
assert_eq!(body[0]["agent"], "atlas");
|
|
||||||
assert_eq!(body[0]["accounts"].as_array().map(Vec::len), Some(5));
|
|
||||||
assert_eq!(body[1]["agent"], "red");
|
|
||||||
assert_eq!(body[1]["accounts"][0]["name"], "catgirl");
|
|
||||||
assert_eq!(body[2]["hive"], serde_json::Value::Null);
|
|
||||||
assert_eq!(body[2]["accounts"], serde_json::json!([]));
|
|
||||||
let text = body.to_string();
|
|
||||||
assert!(!text.contains("value"), "{text}");
|
|
||||||
assert!(!text.contains("t0k3n"), "{text}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn an_agent_with_nothing_stored_lists_nothing() {
|
async fn an_agent_with_nothing_stored_lists_nothing() {
|
||||||
let got = linked_accounts(&FakeStore::default(), "atlas")
|
let got = linked_accounts(&FakeStore::default(), "atlas")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue