Skip to main content

max / makenotwork

7.3 KB · 220 lines History Blame Raw
1 //! DB-layer contract tests for `db::ssh_keys`, git-over-SSH key CRUD + lookup.
2 //!
3 //! These pin the ownership-scoped deletes (an IDOR
4 //! guard on the git surface), the per-user duplicate rejection, and the global
5 //! fingerprint uniqueness (migration 161) that keeps `lookup_user_by_fingerprint`
6 //! unambiguous, one account can't register another's key fingerprint.
7
8 use crate::harness::db::TestDb;
9 use crate::harness::seed_user;
10 use makenotwork::db::ssh_keys;
11
12 #[tokio::test]
13 async fn add_then_lookup_resolves_the_owner() {
14 let db = TestDb::new().await;
15 let user = seed_user(&db.pool, "ssh_lookup").await;
16
17 ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:aaa", "laptop")
18 .await
19 .unwrap();
20
21 let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:aaa")
22 .await
23 .unwrap()
24 .expect("known fingerprint resolves");
25 assert_eq!(found.user_id, user);
26 // The CLI takes its display currency from this one lookup and has no other
27 // source for it, so dropping the column here renders every amount in the
28 // TUI as USD whoever the creator is.
29 assert_eq!(
30 found.settlement_currency,
31 makenotwork::currency::SettlementCurrency::Usd,
32 "a seeded user settles in the column default"
33 );
34
35 // An unknown fingerprint authenticates nobody.
36 assert!(
37 ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:unknown")
38 .await
39 .unwrap()
40 .is_none()
41 );
42 }
43
44 #[tokio::test]
45 async fn lookup_carries_the_console_theme_selection() {
46 let db = TestDb::new().await;
47 let user = seed_user(&db.pool, "ssh_console_theme").await;
48
49 ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:theme", "laptop")
50 .await
51 .unwrap();
52
53 // Never chosen. Absent here becomes an absent `theme_id` on the wire, which
54 // the CLI reads as `ThemeSelection::Follow` — today's behaviour, and what a
55 // CLI talking to a server that predates the column already does.
56 let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
57 .await
58 .unwrap()
59 .expect("known fingerprint resolves");
60 assert_eq!(found.console_theme, None);
61
62 // Following the terminal is a stored choice, not an absence: the row has to
63 // be able to say it, or a creator who pins a theme can never go back.
64 makenotwork::db::users::update_user_console_theme(&db.pool, user, makeover::FOLLOW)
65 .await
66 .unwrap();
67 let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
68 .await
69 .unwrap()
70 .unwrap();
71 assert_eq!(found.console_theme.as_deref(), Some(makeover::FOLLOW));
72
73 // A pinned theme reaches the lookup verbatim. This is the whole point of the
74 // column: the CLI has no other source for the creator's choice.
75 makenotwork::db::users::update_user_console_theme(&db.pool, user, "nord")
76 .await
77 .unwrap();
78 let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
79 .await
80 .unwrap()
81 .unwrap();
82 assert_eq!(found.console_theme.as_deref(), Some("nord"));
83
84 // The public-profile theme is a different question and must not move with it.
85 assert_eq!(
86 makenotwork::db::users::get_user_by_id(&db.pool, user)
87 .await
88 .unwrap()
89 .unwrap()
90 .theme_id,
91 None,
92 "the console choice must not write the profile palette visitors see"
93 );
94 }
95
96 #[tokio::test]
97 async fn duplicate_fingerprint_for_same_user_is_rejected() {
98 let db = TestDb::new().await;
99 let user = seed_user(&db.pool, "ssh_dup").await;
100
101 ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:dup", "one")
102 .await
103 .unwrap();
104 // UNIQUE (user_id, fingerprint), re-registering the same key fails.
105 let dup = ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:dup", "two").await;
106 assert!(
107 dup.is_err(),
108 "the same fingerprint cannot be registered twice by one user"
109 );
110 }
111
112 /// A fingerprint maps to exactly ONE identity, globally. Migration 161 added a
113 /// global `UNIQUE (fingerprint)` on top of the original `(user_id, fingerprint)`
114 /// specifically so one account can't register a public key already tied to
115 /// another account and thereby authenticate as it. This guards
116 /// that fix: a second user registering the same fingerprint is rejected, so
117 /// `lookup_user_by_fingerprint` can never be ambiguous.
118 #[tokio::test]
119 async fn fingerprint_is_globally_unique_across_users() {
120 let db = TestDb::new().await;
121 let first = seed_user(&db.pool, "ssh_first").await;
122 let second = seed_user(&db.pool, "ssh_second").await;
123
124 ssh_keys::add_key(
125 &db.pool,
126 first,
127 "ssh-ed25519 AAAA",
128 "SHA256:shared",
129 "first",
130 )
131 .await
132 .unwrap();
133 // A different account cannot claim the same fingerprint.
134 let stolen = ssh_keys::add_key(
135 &db.pool,
136 second,
137 "ssh-ed25519 AAAA",
138 "SHA256:shared",
139 "second",
140 )
141 .await;
142 assert!(
143 stolen.is_err(),
144 "a fingerprint already registered to one account must not be registrable by another"
145 );
146
147 // The lookup remains unambiguous, it resolves to the sole registrant.
148 let resolved = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:shared")
149 .await
150 .unwrap()
151 .expect("fingerprint resolves");
152 assert_eq!(resolved.user_id, first);
153 }
154
155 /// IDOR guard: `delete_key` scopes on `(id, user_id)`, so one user cannot delete
156 /// another's key by id.
157 #[tokio::test]
158 async fn delete_by_id_is_scoped_to_the_owner() {
159 let db = TestDb::new().await;
160 let owner = seed_user(&db.pool, "ssh_owner").await;
161 let attacker = seed_user(&db.pool, "ssh_attacker").await;
162 let key = ssh_keys::add_key(&db.pool, owner, "ssh-ed25519 AAAA", "SHA256:own", "k")
163 .await
164 .unwrap();
165
166 assert!(
167 !ssh_keys::delete_key(&db.pool, key.id, attacker)
168 .await
169 .unwrap(),
170 "a non-owner delete-by-id must return false"
171 );
172 assert_eq!(
173 ssh_keys::list_keys_by_user(&db.pool, owner)
174 .await
175 .unwrap()
176 .len(),
177 1,
178 "the key survives"
179 );
180
181 assert!(ssh_keys::delete_key(&db.pool, key.id, owner).await.unwrap());
182 assert!(
183 ssh_keys::list_keys_by_user(&db.pool, owner)
184 .await
185 .unwrap()
186 .is_empty()
187 );
188 }
189
190 #[tokio::test]
191 async fn delete_by_fingerprint_is_scoped_to_the_owner() {
192 let db = TestDb::new().await;
193 let owner = seed_user(&db.pool, "ssh_fp_owner").await;
194 let attacker = seed_user(&db.pool, "ssh_fp_attacker").await;
195 ssh_keys::add_key(&db.pool, owner, "ssh-ed25519 AAAA", "SHA256:fp", "k")
196 .await
197 .unwrap();
198
199 // The attacker knows the fingerprint but not the owner scope: no-op.
200 assert!(
201 !ssh_keys::delete_key_by_fingerprint(&db.pool, attacker, "SHA256:fp")
202 .await
203 .unwrap(),
204 "a non-owner delete-by-fingerprint must return false"
205 );
206 assert_eq!(
207 ssh_keys::list_keys_by_user(&db.pool, owner)
208 .await
209 .unwrap()
210 .len(),
211 1
212 );
213
214 assert!(
215 ssh_keys::delete_key_by_fingerprint(&db.pool, owner, "SHA256:fp")
216 .await
217 .unwrap()
218 );
219 }
220