Skip to main content

max / makenotwork

9.8 KB · 313 lines History Blame Raw
1 //! The Alloy hotfix RPM publish endpoint: who may mint a presigned PUT, and
2 //! which object paths it will sign.
3 //!
4 //! The endpoint records nothing and owns no table, so what is worth testing is
5 //! entirely the gate and the validator. Both are the security surface: the gate
6 //! is the only thing between a SyncKit account and write access to a bucket the
7 //! world reads, and the validator is the only thing stopping a signed PUT from
8 //! landing an object somewhere the Caddy block would serve it as something else.
9
10 use crate::harness::{BuildOptions, TestHarness, storage::InMemoryStorage};
11 use makenotwork::db::UserId;
12 use serde::Deserialize;
13 use serde_json::json;
14 use std::sync::Arc;
15
16 #[derive(Deserialize)]
17 struct AuthResponse {
18 token: String,
19 }
20
21 #[derive(Deserialize)]
22 struct PresignResponse {
23 upload_url: String,
24 object_key: String,
25 public_url: Option<String>,
26 content_type: String,
27 }
28
29 /// Build a harness whose configured admin is the account the returned token
30 /// authenticates, so the happy path is reachable.
31 ///
32 /// The admin row `insert_admin_user` writes is the one `with_admin` configures,
33 /// so authenticating as `admin@test.com` is what makes `SyncUser::user_id` and
34 /// `config.admin_user_id` the same value. That equality IS the gate.
35 async fn harness_as_admin(rpm_base_url: Option<String>) -> TestHarness {
36 let test_db = crate::harness::db::TestDb::new().await;
37 let pool = test_db.pool.clone();
38 let admin_id = insert_admin(&pool).await;
39
40 // The harness points `rpm_s3` at the same in-memory backend as `s3`, so a
41 // presign is only mintable when storage is configured at all.
42 let mut h = TestHarness::build(BuildOptions {
43 storage: Some(Arc::new(InMemoryStorage::new())),
44 admin_user_id: Some(admin_id),
45 existing_db: Some(test_db),
46 rpm_base_url,
47 ..Default::default()
48 })
49 .await;
50
51 let api_key = create_sync_app(&h.db, admin_id).await;
52 authenticate(&mut h, "admin@test.com", &api_key).await;
53 h
54 }
55
56 async fn insert_admin(pool: &sqlx::PgPool) -> UserId {
57 let password_hash = makenotwork::auth::hash_password("password123").unwrap();
58 sqlx::query_scalar(
59 "INSERT INTO users (username, email, password_hash, email_verified)
60 VALUES ('admin', 'admin@test.com', $1, true)
61 RETURNING id",
62 )
63 .bind(&password_hash)
64 .fetch_one(pool)
65 .await
66 .expect("insert admin user")
67 }
68
69 async fn create_sync_app(pool: &sqlx::PgPool, owner: UserId) -> String {
70 let api_key = format!("test-rpm-key-{}", uuid::Uuid::new_v4());
71 let key_hash = crate::harness::hash_api_key(&api_key);
72 let key_prefix = &api_key[..8];
73 sqlx::query(
74 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix)
75 VALUES ($1, 'RPM Publisher', $2, $3)",
76 )
77 .bind(owner)
78 .bind(&key_hash)
79 .bind(key_prefix)
80 .execute(pool)
81 .await
82 .expect("insert sync app");
83 api_key
84 }
85
86 async fn authenticate(h: &mut TestHarness, email: &str, api_key: &str) {
87 let resp = h
88 .client
89 .post_json(
90 "/api/sync/auth",
91 &json!({
92 "email": email,
93 "password": "password123",
94 "api_key": api_key,
95 "key": "test-sdk-key",
96 })
97 .to_string(),
98 )
99 .await;
100 assert_eq!(resp.status, 200, "auth failed: {}", resp.text);
101 let auth: AuthResponse = resp.json();
102 h.client.set_bearer_token(&auth.token);
103 }
104
105 async fn presign(
106 h: &mut TestHarness,
107 path: &str,
108 size: i64,
109 ) -> crate::harness::client::TestResponse {
110 h.client
111 .post_json(
112 "/api/v1/admin/rpm/uploads",
113 &json!({ "path": path, "size": size }).to_string(),
114 )
115 .await
116 }
117
118 // ── The gate ──
119
120 #[tokio::test]
121 async fn the_configured_admin_can_mint_a_presign() {
122 let mut h = harness_as_admin(Some("https://rpm.example.test".to_string())).await;
123
124 let resp = presign(&mut h, "alloy/f43/x86_64/repodata/repomd.xml", 512).await;
125 assert_eq!(resp.status, 201, "presign failed: {}", resp.text);
126
127 let body: PresignResponse = resp.json();
128 assert_eq!(body.object_key, "alloy/f43/x86_64/repodata/repomd.xml");
129 assert_eq!(body.content_type, "application/xml");
130 assert_eq!(
131 body.public_url.as_deref(),
132 Some("https://rpm.example.test/alloy/f43/x86_64/repodata/repomd.xml"),
133 "the public URL is the base plus the key, with no rewriting in between"
134 );
135 assert!(
136 body.upload_url.contains("repomd.xml"),
137 "presigned URL should address the key: {}",
138 body.upload_url
139 );
140 }
141
142 #[tokio::test]
143 async fn a_non_admin_gets_404_rather_than_403() {
144 // A separate signed-up account with a valid SyncKit token. It is
145 // authenticated, just not the admin — the case the gate exists for.
146 let mut h = TestHarness::new().await;
147 let user_id = h
148 .signup("someone", "someone@example.com", "Password1!")
149 .await;
150 let api_key = create_sync_app(&h.db, user_id).await;
151
152 let resp = h
153 .client
154 .post_json(
155 "/api/sync/auth",
156 &json!({
157 "email": "someone@example.com",
158 "password": "Password1!",
159 "api_key": api_key,
160 "key": "test-sdk-key",
161 })
162 .to_string(),
163 )
164 .await;
165 assert_eq!(resp.status, 200, "auth failed: {}", resp.text);
166 let auth: AuthResponse = resp.json();
167 h.client.set_bearer_token(&auth.token);
168
169 let resp = presign(&mut h, "alloy/f43/x86_64/repodata/repomd.xml", 512).await;
170 assert_eq!(
171 resp.status, 404,
172 "a non-admin must not learn the endpoint exists: {}",
173 resp.text
174 );
175 }
176
177 #[tokio::test]
178 async fn an_unauthenticated_caller_is_refused() {
179 let mut h = TestHarness::new().await;
180 let resp = presign(&mut h, "alloy/f43/x86_64/repodata/repomd.xml", 512).await;
181 assert_eq!(resp.status, 401, "expected 401, got {}", resp.text);
182 }
183
184 #[tokio::test]
185 async fn no_configured_admin_refuses_everyone() {
186 // `admin_user_id` unset is the dev default. The gate must close, not open:
187 // an unset admin is not "anyone may".
188 let mut h = TestHarness::new().await;
189 let user_id = h
190 .signup("someone", "someone@example.com", "Password1!")
191 .await;
192 let api_key = create_sync_app(&h.db, user_id).await;
193 authenticate_with_password(&mut h, "someone@example.com", "Password1!", &api_key).await;
194
195 let resp = presign(&mut h, "a/repodata/repomd.xml", 512).await;
196 assert_eq!(resp.status, 404, "expected 404, got {}", resp.text);
197 }
198
199 async fn authenticate_with_password(
200 h: &mut TestHarness,
201 email: &str,
202 password: &str,
203 api_key: &str,
204 ) {
205 let resp = h
206 .client
207 .post_json(
208 "/api/sync/auth",
209 &json!({
210 "email": email,
211 "password": password,
212 "api_key": api_key,
213 "key": "test-sdk-key",
214 })
215 .to_string(),
216 )
217 .await;
218 assert_eq!(resp.status, 200, "auth failed: {}", resp.text);
219 let auth: AuthResponse = resp.json();
220 h.client.set_bearer_token(&auth.token);
221 }
222
223 // ── The path validator ──
224
225 #[tokio::test]
226 async fn traversal_and_absolute_paths_are_refused() {
227 let mut h = harness_as_admin(None).await;
228
229 for path in [
230 "../etc/passwd.rpm",
231 "alloy/../../x.rpm",
232 "/alloy/f43/x.rpm",
233 "alloy//f43/x.rpm",
234 "alloy/f43/x.rpm/",
235 "alloy/./x.rpm",
236 ] {
237 let resp = presign(&mut h, path, 512).await;
238 assert_eq!(
239 resp.status, 400,
240 "path {path:?} should have been refused, got {} {}",
241 resp.status, resp.text
242 );
243 }
244 }
245
246 #[tokio::test]
247 async fn only_extensions_an_rpm_repository_serves_are_signed() {
248 let mut h = harness_as_admin(None).await;
249
250 for path in [
251 "alloy/f43/x86_64/alloy-1.0.0-1.fc43.x86_64.rpm",
252 "alloy/f43/x86_64/repodata/abc123-primary.xml.zst",
253 "alloy/f43/x86_64/repodata/repomd.xml.asc",
254 ] {
255 let resp = presign(&mut h, path, 512).await;
256 assert_eq!(
257 resp.status, 201,
258 "path {path:?} should be signed: {}",
259 resp.text
260 );
261 }
262
263 for path in [
264 "alloy/f43/index.html",
265 "alloy/f43/payload.sh",
266 "alloy/f43/noextension",
267 ] {
268 let resp = presign(&mut h, path, 512).await;
269 assert_eq!(
270 resp.status, 400,
271 "path {path:?} is not repository content and should be refused: {}",
272 resp.text
273 );
274 }
275 }
276
277 #[tokio::test]
278 async fn a_package_and_the_index_get_different_content_types() {
279 let mut h = harness_as_admin(None).await;
280
281 let pkg: PresignResponse = presign(&mut h, "a/alloy-1.0.0.rpm", 512).await.json();
282 assert_eq!(pkg.content_type, "application/x-rpm");
283
284 let meta: PresignResponse = presign(&mut h, "a/repodata/abc-primary.xml.zst", 512)
285 .await
286 .json();
287 assert_eq!(meta.content_type, "application/zstd");
288 }
289
290 #[tokio::test]
291 async fn a_size_outside_the_ceiling_is_refused_before_anything_is_signed() {
292 let mut h = harness_as_admin(None).await;
293
294 for size in [0, -1, makenotwork::constants::RPM_MAX_OBJECT_BYTES + 1] {
295 let resp = presign(&mut h, "a/alloy-1.0.0.rpm", size).await;
296 assert_eq!(
297 resp.status, 400,
298 "size {size} should have been refused: {}",
299 resp.text
300 );
301 }
302 }
303
304 #[tokio::test]
305 async fn an_unset_rpm_base_url_answers_with_a_null_public_url() {
306 // Not a degraded mode worth failing on: the endpoint's job is the presign,
307 // and where the object is served from is a separate piece of config the
308 // publisher only prints.
309 let mut h = harness_as_admin(None).await;
310 let body: PresignResponse = presign(&mut h, "a/alloy-1.0.0.rpm", 512).await.json();
311 assert!(body.public_url.is_none());
312 }
313