steid

@jamesgill /

steid/src/application/instance.rs
Rust5.7 KBCode·Blame·History·Raw
1//! What an operator is told about a whole instance.
2//!
3//! Not a page and not an actor's view of anything: the caller is a control plane
4//! holding the operator token, which is a machine credential compared at the transport
5//! rather than a user of the site — see `AppConfig::operator_token`. So this use case
6//! takes no [`Actor`](crate::domain::Actor), and the one rule it does carry is about
7//! what may be said to *whom*: [`InstanceReport::claimed`] is public and everything
8//! else is not.
9
10use crate::domain::repository::{RepoRepository, UserRepository};
11
12use super::{claim::is_claimed, error::Result};
13
14/// The one fact about an instance that is safe to tell anybody.
15///
16/// A provisioner polls this between booting a machine and handing its owner a claim
17/// link, so it has to answer before anyone has signed in. It says nothing else:
18/// version, size and uptime are all fingerprinting material for a stranger and are
19/// gated behind the operator token.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct InstanceState {
22 pub claimed: bool,
23}
24
25/// Everything an operator may be told, the public fact included.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct InstanceReport {
28 pub claimed: bool,
29 /// Repositories on the machine, private ones counted — a size, not a listing.
30 pub repositories: u64,
31 /// Users on the machine. One, on a personal instance, until registration exists.
32 pub users: u64,
33}
34
35/// Whether this instance has an owner yet, and nothing more.
36pub async fn instance_state(users: &impl UserRepository) -> Result<InstanceState> {
37 Ok(InstanceState {
38 claimed: is_claimed(users).await?,
39 })
40}
41
42/// The operator's view: the public fact plus the counts.
43///
44/// Two queries rather than one, because the two ports are separate and a join across
45/// them would be a repository that knows about both. At one row each on a local SQLite
46/// file, an operator poll is cheaper than the request that carried it.
47pub async fn describe_instance(
48 users: &impl UserRepository,
49 repos: &impl RepoRepository,
50) -> Result<InstanceReport> {
51 let user_count = users.count().await?;
52
53 Ok(InstanceReport {
54 // Derived from the count rather than asked for separately: a machine with a
55 // user is claimed, and two queries that could disagree is a bug waiting.
56 claimed: user_count > 0,
57 repositories: repos.count().await?,
58 users: user_count,
59 })
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65 use crate::{
66 domain::{
67 Email, OrgId, Organization, PasswordHash, RepoId, Repository, User, UserId, Visibility,
68 repository::OrgRepository,
69 },
70 infrastructure::repository::{InMemoryOrgRepo, InMemoryRepoRepo, InMemoryUserRepo},
71 };
72 use std::time::{Duration, SystemTime};
73
74 struct Fixture {
75 users: InMemoryUserRepo,
76 orgs: InMemoryOrgRepo,
77 repos: InMemoryRepoRepo,
78 }
79
80 fn fixture() -> Fixture {
81 Fixture {
82 users: InMemoryUserRepo::new(),
83 orgs: InMemoryOrgRepo::new(),
84 repos: InMemoryRepoRepo::new(),
85 }
86 }
87
88 impl Fixture {
89 async fn add_user(&self, handle: &str, email: &str) -> Organization {
90 let org = Organization::new(OrgId::generate(), handle, None).expect("valid org");
91 self.orgs.save(&org).await.expect("save org");
92
93 let user = User::new(
94 UserId::generate(),
95 Email::new(email).expect("valid email"),
96 PasswordHash::from_trusted("$argon2id$test"),
97 org.id.clone(),
98 );
99 self.users.save(&user).await.expect("save user");
100
101 org
102 }
103
104 async fn add_repo(&self, org: &Organization, name: &str, visibility: Visibility) {
105 let repo = Repository::new(
106 RepoId::generate(),
107 org.id.clone(),
108 name,
109 None,
110 visibility,
111 SystemTime::UNIX_EPOCH + Duration::from_secs(1_000),
112 )
113 .expect("valid repo");
114 self.repos.save(&repo).await.expect("save repo");
115 }
116 }
117
118 #[tokio::test]
119 async fn an_unclaimed_instance_says_so_and_counts_nothing() {
120 let f = fixture();
121
122 assert_eq!(
123 instance_state(&f.users).await.expect("state"),
124 InstanceState { claimed: false }
125 );
126 assert_eq!(
127 describe_instance(&f.users, &f.repos).await.expect("report"),
128 InstanceReport {
129 claimed: false,
130 repositories: 0,
131 users: 0
132 }
133 );
134 }
135
136 #[tokio::test]
137 async fn a_claimed_instance_reports_its_size() {
138 let f = fixture();
139 let org = f.add_user("james", "dev@example.com").await;
140 f.add_repo(&org, "steid", Visibility::Public).await;
141 // Private, so this is the check that the count is a size rather than a listing.
142 f.add_repo(&org, "notes", Visibility::Private).await;
143
144 assert!(instance_state(&f.users).await.expect("state").claimed);
145 assert_eq!(
146 describe_instance(&f.users, &f.repos).await.expect("report"),
147 InstanceReport {
148 claimed: true,
149 repositories: 2,
150 users: 1
151 }
152 );
153 }
154
155 #[tokio::test]
156 async fn the_two_calls_agree_about_claimed() {
157 let f = fixture();
158 f.add_user("james", "dev@example.com").await;
159
160 assert_eq!(
161 instance_state(&f.users).await.expect("state").claimed,
162 describe_instance(&f.users, &f.repos)
163 .await
164 .expect("report")
165 .claimed,
166 "the public fact must not depend on which route asked for it"
167 );
168 }
169}