swarm-controller: refuse linking over an existing account
The matrix, forge and github link routes wrote their credential unconditionally, so linking a name that was already linked replaced the working account. For matrix that lost the device the agent's crypto store belongs to (#4838). Each route now reads the account's store path first and answers 409, naming the existing account, when something is stored there. Nothing is written. Replacing an account takes the delete from #4899, then a link. The matrix route checks before password mode's login, so a refused link mints no new device at the homeserver. The check is a read then a write, not an atomic step; two concurrent links to one name can still both pass it. Closes #4856
This commit is contained in:
parent
4a50d29a64
commit
8ad2af735e
10 changed files with 303 additions and 63 deletions
|
|
@ -40,8 +40,8 @@ The PAT is operator-supplied. In the [swarm UI](../swarm/ui.md#linking-external-
|
||||||
open the agent on `/agents`, choose **link github account** and paste the PAT
|
open the agent on `/agents`, choose **link github account** and paste the PAT
|
||||||
(`PUT /api/hives/{hive}/agents/{agent}/github-account`). swarm-controller
|
(`PUT /api/hives/{hive}/agents/{agent}/github-account`). swarm-controller
|
||||||
stores it at `swarm/agents/<agent>/github-token` in the swarm secret store;
|
stores it at `swarm/agents/<agent>/github-token` in the swarm secret store;
|
||||||
no hive writes it. One token per agent: linking again replaces it,
|
no hive writes it. One token per agent: swarm-controller refuses to link
|
||||||
and no route hands it back.
|
another until you delete the stored one, and no route hands it back.
|
||||||
|
|
||||||
The agent's `hive-agent-github-token` unit reads that path under the
|
The agent's `hive-agent-github-token` unit reads that path under the
|
||||||
agent's own store certificate and writes `<state>/github-token` (`0600`,
|
agent's own store certificate and writes `<state>/github-token` (`0600`,
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ of the cell says how.
|
||||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
|
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
|
||||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
|
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
|
||||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
||||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by linking the label again | ✅ the agent re-fetches on a 2-minute timer |
|
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer |
|
||||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by linking it again | ✅ the agent re-fetches on a 2-minute timer |
|
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by deleting it and linking it again | ✅ the agent re-fetches on a 2-minute timer |
|
||||||
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
|
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
|
||||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
||||||
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ store identity, or no queue address, publishes no subagent terminals.
|
||||||
|
|
||||||
Each agent on `/agents` opens three dialogs that write a credential for it
|
Each agent on `/agents` opens three dialogs that write a credential for it
|
||||||
into the swarm secret store through swarm-controller. All three are blind
|
into the swarm secret store through swarm-controller. All three are blind
|
||||||
set/update actions: no route hands a token back.
|
set actions: no route hands a token back. swarm-controller refuses to link a
|
||||||
|
name that already holds an account; delete that account first to replace it.
|
||||||
|
|
||||||
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. Opening an agent makes one request,
|
per account: kind, name and host. Opening an agent makes one request,
|
||||||
|
|
@ -75,12 +76,14 @@ a link dialog closes.
|
||||||
`swarm/agents/<agent>/forge/<label>`. The agent's
|
`swarm/agents/<agent>/forge/<label>`. The agent's
|
||||||
`hive-agent-forge-accounts` unit fetches it into
|
`hive-agent-forge-accounts` unit fetches it into
|
||||||
`<state>/forge-<label>-token` and `<state>/forge-<label>.json`, the files
|
`<state>/forge-<label>-token` and `<state>/forge-<label>.json`, the files
|
||||||
`hive-forge -f <label>` reads. The unit never deletes a pair: linking
|
`hive-forge -f <label>` reads. The unit rewrites a pair whenever the
|
||||||
the same label again overwrites both files, and a pair whose label the
|
store's account for its label differs, and never deletes one: a pair whose
|
||||||
store doesn't list stays untouched.
|
label the store doesn't list stays untouched. swarm-controller answers 409
|
||||||
|
to a link for a label that already holds an account, so replacing one
|
||||||
|
takes a delete, then a new link.
|
||||||
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
|
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
|
||||||
with a personal access token, stored at `swarm/agents/<agent>/github-token`.
|
with a personal access token, stored at `swarm/agents/<agent>/github-token`.
|
||||||
One token per agent: linking again replaces it. The agent's
|
One token per agent. The agent's
|
||||||
`hive-agent-github-token` unit fetches it into `<state>/github-token`,
|
`hive-agent-github-token` unit fetches it into `<state>/github-token`,
|
||||||
the file its `gh` wrapper, git credential helper and GitHub notification
|
the file its `gh` wrapper, git credential helper and GitHub notification
|
||||||
poller read. A `github-token` already in place stays when the store holds
|
poller read. A `github-token` already in place stays when the store holds
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
// <LinkForgeAccountForm> — writes an external forge account (base URL +
|
// <LinkForgeAccountForm> — writes an external forge account (base URL +
|
||||||
// token) for one agent into the swarm secret store.
|
// token) for one agent into the swarm secret store.
|
||||||
// PUTs `/api/hives/{hive}/agents/{agent}/forge-accounts/{label}` — 200
|
// PUTs `/api/hives/{hive}/agents/{agent}/forge-accounts/{label}` — 200
|
||||||
// (`{ url }`) on success, 400/500 as `problem+json`, shown via
|
// (`{ url }`) on success, 400/409/500 as `problem+json`, shown via
|
||||||
// `ApiErrorPanel` like `LinkMatrixAccountForm`.
|
// `ApiErrorPanel` like `LinkMatrixAccountForm`.
|
||||||
//
|
//
|
||||||
// The label is what the agent passes to `hive-forge -f <label>`. A blind
|
// The label is what the agent passes to `hive-forge -f <label>`. A blind
|
||||||
// set/update: no route hands a token back. Linked labels and URLs show on
|
// set: no route hands a token back. Linked labels and URLs show on
|
||||||
// the agent panel (`LinkedAccounts`).
|
// the agent panel (`LinkedAccounts`).
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// <LinkGithubAccountForm> — writes a GitHub personal access token for one
|
// <LinkGithubAccountForm> — writes a GitHub personal access token for one
|
||||||
// agent into the swarm secret store.
|
// agent into the swarm secret store.
|
||||||
// PUTs `/api/hives/{hive}/agents/{agent}/github-account` — 204 on success,
|
// PUTs `/api/hives/{hive}/agents/{agent}/github-account` — 204 on success,
|
||||||
// 400/500 as `problem+json`, shown via `ApiErrorPanel` like
|
// 400/409/500 as `problem+json`, shown via `ApiErrorPanel` like
|
||||||
// `LinkForgeAccountForm`.
|
// `LinkForgeAccountForm`.
|
||||||
//
|
//
|
||||||
// One token per agent. A blind set/update: no route hands a token back.
|
// One token per agent. A blind set: no route hands a token back.
|
||||||
// Whether one is stored shows on the agent panel (`LinkedAccounts`).
|
// Whether one is stored shows on the agent panel (`LinkedAccounts`).
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@
|
||||||
// PUT, straight to swarm-controller — never held here past
|
// PUT, straight to swarm-controller — never held here past
|
||||||
// the request, never stored.
|
// the request, never stored.
|
||||||
// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` —
|
// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` —
|
||||||
// 200 (`{ user_id? }`) on success, 400/500 on the documented failure
|
// 200 (`{ user_id? }`) on success, 400/409/500 on the documented failure
|
||||||
// arms — all handled generically via `readApiError`/`ApiErrorPanel`,
|
// arms — all handled generically via `readApiError`/`ApiErrorPanel`,
|
||||||
// same as every other form here, since the response is `problem+json`
|
// same as every other form here, since the response is `problem+json`
|
||||||
// regardless of which arm fired.
|
// regardless of which arm fired.
|
||||||
//
|
//
|
||||||
// A blind set/update action: no route hands a token back, so there is
|
// A blind set action: no route hands a token back, so there is
|
||||||
// nothing to edit. What is already linked shows on the agent panel
|
// nothing to edit. What is already linked shows on the agent panel
|
||||||
// (`LinkedAccounts`), names and hosts only. That matches "make it 1:1 for now, we will split later" and
|
// (`LinkedAccounts`), names and hosts only. That matches "make it 1:1 for now, we will split later" and
|
||||||
// "adding them and assigning them should be separate things" — both
|
// "adding them and assigning them should be separate things" — both
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use swarm_secret_client::forge;
|
use swarm_secret_client::forge;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use super::linked_accounts::link;
|
||||||
use super::{AppState, error_problem, swarm_hive};
|
use super::{AppState, error_problem, swarm_hive};
|
||||||
|
|
||||||
/// The account to store for one agent's external forge.
|
/// The account to store for one agent's external forge.
|
||||||
|
|
@ -34,10 +35,8 @@ pub struct PutForgeAccountResponse {
|
||||||
url: String,
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store an agent's external forge account.
|
/// Store an agent's external forge account, unless one is stored under the
|
||||||
///
|
/// label already.
|
||||||
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
|
||||||
/// account the agent will next read rather than adding a second one.
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
put,
|
put,
|
||||||
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
|
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
|
||||||
|
|
@ -50,7 +49,8 @@ pub struct PutForgeAccountResponse {
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "stored", body = PutForgeAccountResponse),
|
(status = 200, description = "stored", body = PutForgeAccountResponse),
|
||||||
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
||||||
(status = 500, description = "the store write failed (problem+json)", body = String),
|
(status = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
|
||||||
|
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
||||||
),
|
),
|
||||||
tag = "agents"
|
tag = "agents"
|
||||||
)]
|
)]
|
||||||
|
|
@ -76,16 +76,21 @@ pub async fn put_forge_account(
|
||||||
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())
|
||||||
})?;
|
})?;
|
||||||
store.write(&secret_path, &account).await.map_err(|e| {
|
link(&store, &secret_path, &account).await.map_err(|e| {
|
||||||
// The path names the agent and the label; the value is not in it.
|
// The path names the agent and the label; the value is not in it.
|
||||||
tracing::warn!(path = %secret_path, error = %e, "writing the forge account failed");
|
tracing::warn!(path = %secret_path, error = ?e, "linking the forge account failed");
|
||||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
e.problem(&existing(&agent, &label))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
|
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
|
||||||
Ok(Json(PutForgeAccountResponse { url: account.url }))
|
Ok(Json(PutForgeAccountResponse { url: account.url }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The account a refused link names.
|
||||||
|
fn existing(agent: &str, label: &str) -> String {
|
||||||
|
format!("agent {agent} already has forge account {label:?}")
|
||||||
|
}
|
||||||
|
|
||||||
/// The request as it is stored, or why it cannot be.
|
/// The request as it is stored, or why it cannot be.
|
||||||
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
|
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
|
||||||
let url = req.url.trim().trim_end_matches('/');
|
let url = req.url.trim().trim_end_matches('/');
|
||||||
|
|
@ -197,4 +202,38 @@ mod tests {
|
||||||
"{problem:?}"
|
"{problem:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
|
||||||
|
use super::super::linked_accounts::{AccountStore, link, tests::FakeStore};
|
||||||
|
use swarm_secret_client::forge;
|
||||||
|
|
||||||
|
let store = FakeStore::default();
|
||||||
|
let path = forge::account_path("atlas", "codeberg").expect("a valid path");
|
||||||
|
let first = account(request("https://codeberg.org", "t0k3n-first")).expect("valid");
|
||||||
|
let second = account(request("https://forge.lan", "t0k3n-second")).expect("valid");
|
||||||
|
|
||||||
|
link(&store, &path, &first)
|
||||||
|
.await
|
||||||
|
.expect("nothing is stored");
|
||||||
|
let problem = link(&store, &path, &second)
|
||||||
|
.await
|
||||||
|
.expect_err("an account is stored")
|
||||||
|
.problem(&super::existing("atlas", "codeberg"));
|
||||||
|
|
||||||
|
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
||||||
|
let detail = problem.detail.expect("a detail");
|
||||||
|
assert!(
|
||||||
|
detail.contains(r#"agent atlas already has forge account "codeberg""#),
|
||||||
|
"{detail}"
|
||||||
|
);
|
||||||
|
assert_eq!(store.written(), std::slice::from_ref(&path));
|
||||||
|
let kept: forge::Account = store
|
||||||
|
.read_optional(&path)
|
||||||
|
.await
|
||||||
|
.expect("store answers")
|
||||||
|
.expect("still stored");
|
||||||
|
assert_eq!(kept.url, "https://codeberg.org");
|
||||||
|
assert_eq!(kept.value, "t0k3n-first");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use serde::Deserialize;
|
||||||
use swarm_secret_client::github;
|
use swarm_secret_client::github;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use super::linked_accounts::link;
|
||||||
use super::{AppState, error_problem, swarm_hive};
|
use super::{AppState, error_problem, swarm_hive};
|
||||||
|
|
||||||
/// The token to store for one agent.
|
/// The token to store for one agent.
|
||||||
|
|
@ -24,10 +25,7 @@ pub struct PutGithubAccountRequest {
|
||||||
token: String,
|
token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store an agent's GitHub token.
|
/// Store an agent's GitHub token, unless it has one stored already.
|
||||||
///
|
|
||||||
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
|
||||||
/// token the agent will next read.
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
put,
|
put,
|
||||||
path = "/api/hives/{hive}/agents/{agent}/github-account",
|
path = "/api/hives/{hive}/agents/{agent}/github-account",
|
||||||
|
|
@ -39,7 +37,8 @@ pub struct PutGithubAccountRequest {
|
||||||
responses(
|
responses(
|
||||||
(status = 204, description = "stored"),
|
(status = 204, description = "stored"),
|
||||||
(status = 400, description = "the agent is not an identifier, the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
(status = 400, description = "the agent is not an identifier, the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
||||||
(status = 500, description = "the store write failed (problem+json)", body = String),
|
(status = 409, description = "the agent has a token stored already; nothing was written (problem+json)", body = String),
|
||||||
|
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
||||||
),
|
),
|
||||||
tag = "agents"
|
tag = "agents"
|
||||||
)]
|
)]
|
||||||
|
|
@ -60,16 +59,21 @@ pub async fn put_github_account(
|
||||||
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())
|
||||||
})?;
|
})?;
|
||||||
store.write(&secret_path, &credential).await.map_err(|e| {
|
link(&store, &secret_path, &credential).await.map_err(|e| {
|
||||||
// The path names the agent; the value is not in it.
|
// The path names the agent; the value is not in it.
|
||||||
tracing::warn!(path = %secret_path, error = %e, "writing the github token failed");
|
tracing::warn!(path = %secret_path, error = ?e, "linking the github token failed");
|
||||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
e.problem(&existing(&agent))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!(%hive, %agent, "github token stored");
|
tracing::info!(%hive, %agent, "github token stored");
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The token a refused link names.
|
||||||
|
fn existing(agent: &str) -> String {
|
||||||
|
format!("agent {agent} already has a github token")
|
||||||
|
}
|
||||||
|
|
||||||
/// The request as it is stored, or why it cannot be.
|
/// The request as it is stored, or why it cannot be.
|
||||||
fn credential(req: &PutGithubAccountRequest) -> Result<github::Credential, &'static str> {
|
fn credential(req: &PutGithubAccountRequest) -> Result<github::Credential, &'static str> {
|
||||||
let token = req.token.trim();
|
let token = req.token.trim();
|
||||||
|
|
@ -173,4 +177,37 @@ mod tests {
|
||||||
"{problem:?}"
|
"{problem:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn linking_a_second_token_is_a_409_and_the_first_stays() {
|
||||||
|
use super::super::linked_accounts::{AccountStore, link, tests::FakeStore};
|
||||||
|
use swarm_secret_client::github;
|
||||||
|
|
||||||
|
let store = FakeStore::default();
|
||||||
|
let path = github::account_path("atlas").expect("a valid path");
|
||||||
|
let first = credential(&request("t0k3n-first")).expect("valid");
|
||||||
|
let second = credential(&request("t0k3n-second")).expect("valid");
|
||||||
|
|
||||||
|
link(&store, &path, &first)
|
||||||
|
.await
|
||||||
|
.expect("nothing is stored");
|
||||||
|
let problem = link(&store, &path, &second)
|
||||||
|
.await
|
||||||
|
.expect_err("a token is stored")
|
||||||
|
.problem(&super::existing("atlas"));
|
||||||
|
|
||||||
|
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
||||||
|
let detail = problem.detail.expect("a detail");
|
||||||
|
assert!(
|
||||||
|
detail.contains("agent atlas already has a github token"),
|
||||||
|
"{detail}"
|
||||||
|
);
|
||||||
|
assert_eq!(store.written(), std::slice::from_ref(&path));
|
||||||
|
let kept: github::Credential = store
|
||||||
|
.read_optional(&path)
|
||||||
|
.await
|
||||||
|
.expect("store answers")
|
||||||
|
.expect("still stored");
|
||||||
|
assert_eq!(kept.value, "t0k3n-first");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
//! The accounts linked to one 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 agent panel lists, and deletes.
|
//! UI's agent panel lists, and deletes.
|
||||||
//!
|
//!
|
||||||
|
//! [`link`] is the write the three link routes share: it refuses a path that
|
||||||
|
//! already holds an account, so replacing one takes a delete first.
|
||||||
|
//!
|
||||||
//! 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,
|
||||||
//! which `matrix_account::agent_token` mints into the same directory. Each
|
//! which `matrix_account::agent_token` mints into the same directory. Each
|
||||||
|
|
@ -57,8 +60,8 @@ pub struct LinkedAccount {
|
||||||
reserved: bool,
|
reserved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The store calls a listing or a delete makes, so a test can stand in for the
|
/// The store calls a listing, a link or a delete makes, so a test can stand in
|
||||||
/// store.
|
/// for the store.
|
||||||
pub(crate) trait AccountStore {
|
pub(crate) trait AccountStore {
|
||||||
/// As [`SecretStore::list`].
|
/// As [`SecretStore::list`].
|
||||||
fn list(&self, dir: &str) -> impl Future<Output = Result<Vec<String>, Error>> + Send;
|
fn list(&self, dir: &str) -> impl Future<Output = Result<Vec<String>, Error>> + Send;
|
||||||
|
|
@ -69,6 +72,13 @@ pub(crate) trait AccountStore {
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> impl Future<Output = Result<Option<T>, Error>> + Send;
|
) -> impl Future<Output = Result<Option<T>, Error>> + Send;
|
||||||
|
|
||||||
|
/// As [`SecretStore::write`].
|
||||||
|
fn write<T: Serialize + Sync>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
value: &T,
|
||||||
|
) -> impl Future<Output = Result<(), Error>> + Send;
|
||||||
|
|
||||||
/// As [`SecretStore::delete_all_versions`].
|
/// As [`SecretStore::delete_all_versions`].
|
||||||
fn delete_all_versions(&self, path: &str) -> impl Future<Output = Result<(), Error>> + Send;
|
fn delete_all_versions(&self, path: &str) -> impl Future<Output = Result<(), Error>> + Send;
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +95,10 @@ impl AccountStore for SecretStore {
|
||||||
SecretStore::read_optional(self, path).await
|
SecretStore::read_optional(self, path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn write<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
|
||||||
|
SecretStore::write(self, path, value).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
|
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
|
||||||
SecretStore::delete_all_versions(self, path).await
|
SecretStore::delete_all_versions(self, path).await
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +203,54 @@ pub async fn get_linked_accounts(
|
||||||
Ok(Json(accounts))
|
Ok(Json(accounts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why an account was not linked.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) enum Linking {
|
||||||
|
/// An account is stored at the path already. Nothing was written.
|
||||||
|
Exists,
|
||||||
|
/// The store refused or could not be reached.
|
||||||
|
Store(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Linking {
|
||||||
|
/// `existing` names the stored account, as in
|
||||||
|
/// `agent janet already has matrix account "work"`.
|
||||||
|
pub(crate) fn problem(&self, existing: &str) -> problem_details::ProblemDetails {
|
||||||
|
match self {
|
||||||
|
Self::Exists => error_problem(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
&format!("{existing} linked; delete it first"),
|
||||||
|
),
|
||||||
|
Self::Store(e) => error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Linking::Exists`] when an account is stored at `path`.
|
||||||
|
pub(crate) async fn refuse_linked<T: DeserializeOwned + Send>(
|
||||||
|
store: &impl AccountStore,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<(), Linking> {
|
||||||
|
match store.read_optional::<T>(path).await {
|
||||||
|
Ok(None) => Ok(()),
|
||||||
|
Ok(Some(_)) => Err(Linking::Exists),
|
||||||
|
Err(e) => Err(Linking::Store(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `value` at `path`, unless an account is stored there already.
|
||||||
|
///
|
||||||
|
/// A read then a write, not one atomic step: two links racing for one path can
|
||||||
|
/// both pass the check, and the later write wins.
|
||||||
|
pub(crate) async fn link<T: Serialize + DeserializeOwned + Send + Sync>(
|
||||||
|
store: &impl AccountStore,
|
||||||
|
path: &str,
|
||||||
|
value: &T,
|
||||||
|
) -> Result<(), Linking> {
|
||||||
|
refuse_linked::<T>(store, path).await?;
|
||||||
|
store.write(path, value).await.map_err(Linking::Store)
|
||||||
|
}
|
||||||
|
|
||||||
/// Why an account was not deleted.
|
/// Why an account was not deleted.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) enum Removal {
|
pub(crate) enum Removal {
|
||||||
|
|
@ -431,10 +493,11 @@ pub async fn delete_github_account(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
pub(crate) mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use swarm_secret_client::Error;
|
use swarm_secret_client::Error;
|
||||||
|
|
@ -447,19 +510,29 @@ mod tests {
|
||||||
|
|
||||||
/// 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
|
||||||
/// does. A delete is recorded in `deleted` and leaves `objects` as it is.
|
/// does. A write replaces the object and is recorded in `written`; a
|
||||||
|
/// delete is recorded in `deleted` and leaves `objects` as it is.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct FakeStore {
|
pub(crate) struct FakeStore {
|
||||||
objects: BTreeMap<String, Value>,
|
objects: Mutex<BTreeMap<String, Value>>,
|
||||||
denied: bool,
|
denied: bool,
|
||||||
|
written: Mutex<Vec<String>>,
|
||||||
deleted: Mutex<Vec<String>>,
|
deleted: Mutex<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FakeStore {
|
impl FakeStore {
|
||||||
fn with(mut self, path: &str, object: Value) -> Self {
|
fn with(self, path: &str, object: Value) -> Self {
|
||||||
self.objects.insert(path.to_owned(), object);
|
self.objects
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.insert(path.to_owned(), object);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The paths written, in order.
|
||||||
|
pub(crate) fn written(&self) -> Vec<String> {
|
||||||
|
self.written.lock().expect("not poisoned").clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AccountStore for FakeStore {
|
impl AccountStore for FakeStore {
|
||||||
|
|
@ -470,6 +543,8 @@ mod tests {
|
||||||
let prefix = format!("{dir}/");
|
let prefix = format!("{dir}/");
|
||||||
let mut keys: Vec<String> = self
|
let mut keys: Vec<String> = self
|
||||||
.objects
|
.objects
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
.keys()
|
.keys()
|
||||||
.filter_map(|p| p.strip_prefix(&prefix))
|
.filter_map(|p| p.strip_prefix(&prefix))
|
||||||
.map(|rest| match rest.split_once('/') {
|
.map(|rest| match rest.split_once('/') {
|
||||||
|
|
@ -490,10 +565,28 @@ mod tests {
|
||||||
}
|
}
|
||||||
Ok(self
|
Ok(self
|
||||||
.objects
|
.objects
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
.get(path)
|
.get(path)
|
||||||
.map(|v| serde_json::from_value(v.clone()).expect("fixture decodes")))
|
.map(|v| serde_json::from_value(v.clone()).expect("fixture decodes")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn write<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
|
||||||
|
if self.denied {
|
||||||
|
return Err(Error::MissingEnv("BAO_ADDR"));
|
||||||
|
}
|
||||||
|
let value = serde_json::to_value(value).expect("serialises");
|
||||||
|
self.objects
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.insert(path.to_owned(), value);
|
||||||
|
self.written
|
||||||
|
.lock()
|
||||||
|
.expect("not poisoned")
|
||||||
|
.push(path.to_owned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
|
async fn delete_all_versions(&self, path: &str) -> Result<(), Error> {
|
||||||
if self.denied {
|
if self.denied {
|
||||||
return Err(Error::MissingEnv("BAO_ADDR"));
|
return Err(Error::MissingEnv("BAO_ADDR"));
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use swarm_secret_client::matrix;
|
use swarm_secret_client::matrix;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use super::linked_accounts::{AccountStore, Linking, link, refuse_linked};
|
||||||
use super::{AppState, error_problem, swarm_hive};
|
use super::{AppState, error_problem, swarm_hive};
|
||||||
|
|
||||||
pub mod agent_token;
|
pub mod agent_token;
|
||||||
|
|
@ -117,10 +118,8 @@ pub struct PutMatrixAccountResponse {
|
||||||
user_id: Option<String>,
|
user_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store an agent's external matrix account credential.
|
/// Store an agent's external matrix account credential, unless an account is
|
||||||
///
|
/// stored under the name already.
|
||||||
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
|
||||||
/// value the agent will next read rather than adding a second account.
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
put,
|
put,
|
||||||
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
|
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
|
||||||
|
|
@ -133,7 +132,8 @@ pub struct PutMatrixAccountResponse {
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "stored", body = PutMatrixAccountResponse),
|
(status = 200, description = "stored", body = PutMatrixAccountResponse),
|
||||||
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
|
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
|
||||||
(status = 500, description = "the store write failed (problem+json)", body = String),
|
(status = 409, description = "an account is stored under the name already; nothing was written and no login was made (problem+json)", body = String),
|
||||||
|
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
||||||
),
|
),
|
||||||
tag = "agents"
|
tag = "agents"
|
||||||
)]
|
)]
|
||||||
|
|
@ -166,33 +166,57 @@ pub async fn put_matrix_account(
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password mode's network call happens here, before the store is
|
|
||||||
// touched, so a failed login leaves no partial state behind.
|
|
||||||
let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?;
|
|
||||||
|
|
||||||
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())
|
||||||
})?;
|
})?;
|
||||||
store
|
let user_id = link_matrix(&store, &secret_path, &existing(&agent, &account), &req)
|
||||||
.write(
|
|
||||||
&secret_path,
|
|
||||||
&matrix::Credential {
|
|
||||||
value: token,
|
|
||||||
homeserver,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|b| *b)?;
|
||||||
// The path names the agent and the account; the value is not in it.
|
|
||||||
tracing::warn!(path = %secret_path, error = %e, "writing the credential failed");
|
|
||||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!(%hive, %agent, %account, "credential stored");
|
tracing::info!(%hive, %agent, %account, "credential stored");
|
||||||
Ok(Json(PutMatrixAccountResponse { user_id }))
|
Ok(Json(PutMatrixAccountResponse { user_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The account a refused link names.
|
||||||
|
fn existing(agent: &str, account: &str) -> String {
|
||||||
|
format!("agent {agent} already has matrix account {account:?}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve `req`'s credential and write it at `path`, unless an account is
|
||||||
|
/// stored there already; `existing` names that account in the 409.
|
||||||
|
///
|
||||||
|
/// The check comes before password mode's login, so a refused link makes no
|
||||||
|
/// login and so mints no device at the homeserver. A failed login writes
|
||||||
|
/// nothing.
|
||||||
|
async fn link_matrix(
|
||||||
|
store: &impl AccountStore,
|
||||||
|
path: &str,
|
||||||
|
existing: &str,
|
||||||
|
req: &PutMatrixAccountRequest,
|
||||||
|
) -> Result<Option<String>, Box<problem_details::ProblemDetails>> {
|
||||||
|
let refused = |e: Linking| {
|
||||||
|
// The path names the agent and the account; the value is not in it.
|
||||||
|
tracing::warn!(path, error = ?e, "linking the matrix account failed");
|
||||||
|
Box::new(e.problem(existing))
|
||||||
|
};
|
||||||
|
refuse_linked::<matrix::Credential>(store, path)
|
||||||
|
.await
|
||||||
|
.map_err(refused)?;
|
||||||
|
let (token, homeserver, user_id) = resolve_credential(req).await?;
|
||||||
|
link(
|
||||||
|
store,
|
||||||
|
path,
|
||||||
|
&matrix::Credential {
|
||||||
|
value: token,
|
||||||
|
homeserver,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(refused)?;
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether `account` is the agent's own account, which [`agent_token`] mints
|
/// Whether `account` is the agent's own account, which [`agent_token`] mints
|
||||||
/// and `nix/agent-modules/matrix.nix` declares per agent — see
|
/// and `nix/agent-modules/matrix.nix` declares per agent — see
|
||||||
/// [`put_matrix_account`]'s comment on it for why that route must never write
|
/// [`put_matrix_account`]'s comment on it for why that route must never write
|
||||||
|
|
@ -252,8 +276,8 @@ fn password_fields(req: &PutMatrixAccountRequest) -> Result<PasswordFields<'_>,
|
||||||
///
|
///
|
||||||
/// `Box`ed error for the same `result_large_err` reason `token_credential`'s
|
/// `Box`ed error for the same `result_large_err` reason `token_credential`'s
|
||||||
/// doc explains — this fn is private too, so it does not get `put_matrix_account`'s
|
/// doc explains — this fn is private too, so it does not get `put_matrix_account`'s
|
||||||
/// exported-API exemption. Unboxed at the one call site instead of changing
|
/// exported-API exemption. Unboxed in `put_matrix_account` instead of
|
||||||
/// `put_matrix_account`'s own (exempt, and part of the route's documented
|
/// changing that fn's own (exempt, and part of the route's documented
|
||||||
/// contract) return type.
|
/// contract) return type.
|
||||||
async fn resolve_credential(
|
async fn resolve_credential(
|
||||||
req: &PutMatrixAccountRequest,
|
req: &PutMatrixAccountRequest,
|
||||||
|
|
@ -612,6 +636,50 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The second link is password mode against a port nothing listens on:
|
||||||
|
/// a login attempt would answer 400, so the 409 is the check running first.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn linking_a_name_twice_is_a_409_and_the_first_account_stays() {
|
||||||
|
use super::super::linked_accounts::{AccountStore, tests::FakeStore};
|
||||||
|
use swarm_secret_client::matrix;
|
||||||
|
|
||||||
|
let store = FakeStore::default();
|
||||||
|
let path = matrix::account_path("atlas", "workaccount").expect("a valid path");
|
||||||
|
let existing = &super::existing("atlas", "workaccount");
|
||||||
|
let mut first = request("token");
|
||||||
|
first.token = Some("t0k3n-first".to_owned());
|
||||||
|
first.homeserver = Some("https://matrix.example.org".to_owned());
|
||||||
|
let mut second = request("password");
|
||||||
|
second.homeserver = Some("http://127.0.0.1:9".to_owned());
|
||||||
|
second.user_id = Some("@a:matrix.example.org".to_owned());
|
||||||
|
second.password = Some("hunter2".to_owned());
|
||||||
|
|
||||||
|
super::link_matrix(&store, &path, existing, &first)
|
||||||
|
.await
|
||||||
|
.expect("nothing is stored");
|
||||||
|
let problem = super::link_matrix(&store, &path, existing, &second)
|
||||||
|
.await
|
||||||
|
.expect_err("an account is stored");
|
||||||
|
|
||||||
|
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
||||||
|
let detail = problem.detail.expect("a detail");
|
||||||
|
assert_eq!(
|
||||||
|
detail,
|
||||||
|
r#"agent atlas already has matrix account "workaccount" linked; delete it first"#
|
||||||
|
);
|
||||||
|
assert_eq!(store.written(), std::slice::from_ref(&path));
|
||||||
|
let kept: matrix::Credential = store
|
||||||
|
.read_optional(&path)
|
||||||
|
.await
|
||||||
|
.expect("store answers")
|
||||||
|
.expect("still stored");
|
||||||
|
assert_eq!(kept.value, "t0k3n-first");
|
||||||
|
assert_eq!(
|
||||||
|
kept.homeserver.as_deref(),
|
||||||
|
Some("https://matrix.example.org")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A stand-in homeserver on a loopback port, answering every
|
/// A stand-in homeserver on a loopback port, answering every
|
||||||
/// `/_matrix/client/v3/logout` with `status` and `body`.
|
/// `/_matrix/client/v3/logout` with `status` and `body`.
|
||||||
async fn stub_homeserver(status: u16, body: &'static str) -> String {
|
async fn stub_homeserver(status: u16, body: &'static str) -> String {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue