Skip to main content

max / makenotwork

14.4 KB · 529 lines History Blame Raw
1 //! Build pipeline integration tests, config CRUD, triggers, cancellation.
2
3 use crate::harness::{BuildOptions, TestHarness};
4 use makenotwork::db::{BuildConfigId, BuildId, GitRepoId, SyncAppId, UserId};
5 use serde::Deserialize;
6 use serde_json::json;
7 use sqlx::PgPool;
8
9 // ── Response types ──
10
11 #[derive(Deserialize)]
12 struct AuthResponse {
13 token: String,
14 #[serde(rename = "user_id")]
15 _user_id: UserId,
16 #[serde(rename = "app_id")]
17 _app_id: SyncAppId,
18 }
19
20 #[derive(Deserialize)]
21 struct ConfigResponse {
22 id: BuildConfigId,
23 app_id: SyncAppId,
24 repo_id: GitRepoId,
25 build_command: String,
26 artifact_path: String,
27 targets: Vec<String>,
28 enabled: bool,
29 }
30
31 #[derive(Deserialize)]
32 struct BuildResponse {
33 id: BuildId,
34 version: String,
35 tag: String,
36 status: String,
37 triggered_by: String,
38 }
39
40 // ── Helpers ──
41
42 /// Insert a sync app directly via SQL.
43 async fn create_sync_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
44 let api_key = format!("test-build-key-{}", uuid::Uuid::new_v4());
45 let key_hash = crate::harness::hash_api_key(&api_key);
46 let key_prefix = &api_key[..8];
47 let app_id: SyncAppId = sqlx::query_scalar(
48 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'Build App', $2, $3) RETURNING id",
49 )
50 .bind(user_id)
51 .bind(&key_hash)
52 .bind(key_prefix)
53 .fetch_one(pool)
54 .await
55 .expect("Failed to create sync app");
56
57 (app_id, api_key)
58 }
59
60 /// Insert a git repo directly via SQL.
61 async fn create_git_repo(pool: &PgPool, user_id: UserId, name: &str) -> GitRepoId {
62 let repo_id: GitRepoId =
63 sqlx::query_scalar("INSERT INTO git_repos (user_id, name) VALUES ($1, $2) RETURNING id")
64 .bind(user_id)
65 .bind(name)
66 .fetch_one(pool)
67 .await
68 .expect("Failed to create git repo");
69
70 repo_id
71 }
72
73 /// Sign up, create an app + repo, get a JWT token.
74 async fn setup_authenticated(h: &mut TestHarness) -> (SyncAppId, GitRepoId) {
75 let user_id = h
76 .signup("builduser", "build@example.com", "Password1!")
77 .await;
78 let (app_id, api_key) = create_sync_app(&h.db, user_id).await;
79 let repo_id = create_git_repo(&h.db, user_id, "test-repo").await;
80
81 let resp = h
82 .client
83 .post_json(
84 "/api/sync/auth",
85 &json!({
86 "email": "build@example.com",
87 "password": "Password1!",
88 "api_key": api_key,
89 "key": "test-sdk-key",
90 })
91 .to_string(),
92 )
93 .await;
94 assert_eq!(resp.status, 200, "Auth failed: {}", resp.text);
95
96 let auth: AuthResponse = resp.json();
97 h.client.set_bearer_token(&auth.token);
98
99 (app_id, repo_id)
100 }
101
102 /// Build a harness with a build trigger token set.
103 #[allow(dead_code)]
104 async fn harness_with_trigger_token() -> TestHarness {
105 TestHarness::build(BuildOptions {
106 build_trigger_token: Some("test-trigger-token".to_string()),
107 ..Default::default()
108 })
109 .await
110 }
111
112 // ── Tests ──
113
114 #[tokio::test]
115 async fn create_build_config_and_retrieve() {
116 let mut h = TestHarness::new().await;
117 let (app_id, repo_id) = setup_authenticated(&mut h).await;
118
119 // Create config
120 let resp = h
121 .client
122 .post_json(
123 &format!("/api/sync/builds/apps/{app_id}/config"),
124 &json!({
125 "repo_id": repo_id,
126 "build_command": "cargo build --release --target {target}",
127 "artifact_path": "target/{target}/release/myapp",
128 "targets": ["linux/x86_64", "linux/aarch64"]
129 })
130 .to_string(),
131 )
132 .await;
133 assert_eq!(resp.status, 201, "Create config failed: {}", resp.text);
134 let config: ConfigResponse = resp.json();
135 assert_eq!(config.app_id, app_id);
136 assert_eq!(config.repo_id, repo_id);
137 assert_eq!(config.targets, vec!["linux/x86_64", "linux/aarch64"]);
138 assert!(config.enabled);
139
140 // Retrieve
141 let resp = h
142 .client
143 .get(&format!("/api/sync/builds/apps/{app_id}/config"))
144 .await;
145 assert_eq!(resp.status, 200);
146 let retrieved: ConfigResponse = resp.json();
147 assert_eq!(retrieved.id, config.id);
148 assert_eq!(
149 retrieved.build_command,
150 "cargo build --release --target {target}"
151 );
152 }
153
154 #[tokio::test]
155 async fn update_build_config() {
156 let mut h = TestHarness::new().await;
157 let (app_id, repo_id) = setup_authenticated(&mut h).await;
158
159 // Create
160 let resp = h
161 .client
162 .post_json(
163 &format!("/api/sync/builds/apps/{app_id}/config"),
164 &json!({
165 "repo_id": repo_id,
166 "build_command": "make build",
167 "artifact_path": "dist/app",
168 "targets": ["linux/x86_64"]
169 })
170 .to_string(),
171 )
172 .await;
173 assert_eq!(resp.status, 201);
174
175 // Update
176 let resp = h
177 .client
178 .put_json(
179 &format!("/api/sync/builds/apps/{app_id}/config"),
180 &json!({
181 "build_command": "make release",
182 "artifact_path": "dist/app-v2",
183 "targets": ["linux/x86_64", "darwin/aarch64"],
184 "enabled": false
185 })
186 .to_string(),
187 )
188 .await;
189 assert_eq!(resp.status, 200, "Update failed: {}", resp.text);
190 let config: ConfigResponse = resp.json();
191 assert_eq!(config.build_command, "make release");
192 assert_eq!(config.artifact_path, "dist/app-v2");
193 assert!(!config.enabled);
194 assert_eq!(config.targets.len(), 2);
195 }
196
197 #[tokio::test]
198 async fn delete_build_config() {
199 let mut h = TestHarness::new().await;
200 let (app_id, repo_id) = setup_authenticated(&mut h).await;
201
202 // Create
203 let resp = h
204 .client
205 .post_json(
206 &format!("/api/sync/builds/apps/{app_id}/config"),
207 &json!({
208 "repo_id": repo_id,
209 "build_command": "make",
210 "artifact_path": "out/app",
211 "targets": ["linux/x86_64"]
212 })
213 .to_string(),
214 )
215 .await;
216 assert_eq!(resp.status, 201);
217
218 // Delete
219 let resp = h
220 .client
221 .delete(&format!("/api/sync/builds/apps/{app_id}/config"))
222 .await;
223 assert_eq!(resp.status, 204, "Delete failed: {}", resp.text);
224
225 // Verify gone
226 let resp = h
227 .client
228 .get(&format!("/api/sync/builds/apps/{app_id}/config"))
229 .await;
230 assert_eq!(resp.status, 404);
231 }
232
233 #[tokio::test]
234 async fn invalid_target_rejected() {
235 let mut h = TestHarness::new().await;
236 let (app_id, repo_id) = setup_authenticated(&mut h).await;
237
238 let resp = h
239 .client
240 .post_json(
241 &format!("/api/sync/builds/apps/{app_id}/config"),
242 &json!({
243 "repo_id": repo_id,
244 "build_command": "make",
245 "artifact_path": "out/app",
246 "targets": ["windows/x86_64"]
247 })
248 .to_string(),
249 )
250 .await;
251 assert_eq!(
252 resp.status, 400,
253 "Should reject invalid target: {}",
254 resp.text
255 );
256 }
257
258 #[tokio::test]
259 async fn build_config_ownership_check() {
260 let mut h = TestHarness::new().await;
261
262 // User A creates app + repo
263 let user_a = h.signup("usera", "a@example.com", "Password1!").await;
264 let (app_a_id, _) = create_sync_app(&h.db, user_a).await;
265
266 // User B signs up, gets JWT
267 let user_b = h.signup("userb", "b@example.com", "Password1!").await;
268 let (_, api_key_b) = create_sync_app(&h.db, user_b).await;
269 let repo_b_id = create_git_repo(&h.db, user_b, "b-repo").await;
270
271 let resp = h
272 .client
273 .post_json(
274 "/api/sync/auth",
275 &json!({
276 "email": "b@example.com",
277 "password": "Password1!",
278 "api_key": api_key_b,
279 "key": "test-sdk-key",
280 })
281 .to_string(),
282 )
283 .await;
284 let auth_b: AuthResponse = resp.json();
285 h.client.set_bearer_token(&auth_b.token);
286
287 // User B tries to create config on User A's app
288 let resp = h
289 .client
290 .post_json(
291 &format!("/api/sync/builds/apps/{app_a_id}/config"),
292 &json!({
293 "repo_id": repo_b_id,
294 "build_command": "make",
295 "artifact_path": "out/app",
296 "targets": ["linux/x86_64"]
297 })
298 .to_string(),
299 )
300 .await;
301 assert_eq!(resp.status, 403, "Should deny cross-user access");
302 }
303
304 #[tokio::test]
305 async fn hook_trigger_unconfigured() {
306 // No build_trigger_token set → 503
307 let mut h = TestHarness::new().await;
308
309 let resp = h
310 .client
311 .request_with_headers(
312 "POST",
313 "/api/internal/builds/trigger",
314 Some(
315 &json!({
316 "repo_owner": "someone",
317 "repo_name": "something",
318 "tag": "v1.0.0"
319 })
320 .to_string(),
321 ),
322 &[
323 ("authorization", "Bearer whatever"),
324 ("content-type", "application/json"),
325 ],
326 )
327 .await;
328 assert_eq!(
329 resp.status, 503,
330 "Should return 503 when BUILD_TRIGGER_TOKEN not set: {}",
331 resp.text
332 );
333 }
334
335 #[tokio::test]
336 async fn manual_trigger_creates_build() {
337 let mut h = TestHarness::new().await;
338 let (app_id, repo_id) = setup_authenticated(&mut h).await;
339
340 // Create config
341 let resp = h
342 .client
343 .post_json(
344 &format!("/api/sync/builds/apps/{app_id}/config"),
345 &json!({
346 "repo_id": repo_id,
347 "build_command": "make",
348 "artifact_path": "out/app",
349 "targets": ["linux/x86_64"]
350 })
351 .to_string(),
352 )
353 .await;
354 assert_eq!(resp.status, 201);
355
356 // Trigger build
357 let resp = h
358 .client
359 .post_json(
360 &format!("/api/sync/builds/apps/{app_id}/trigger"),
361 &json!({ "tag": "v0.2.2" }).to_string(),
362 )
363 .await;
364 assert_eq!(resp.status, 201, "Manual trigger failed: {}", resp.text);
365 let build: BuildResponse = resp.json();
366 assert_eq!(build.version, "0.2.2");
367 assert_eq!(build.tag, "v0.2.2");
368 assert_eq!(build.status, "pending");
369 assert_eq!(build.triggered_by, "manual");
370
371 // List builds
372 let resp = h
373 .client
374 .get(&format!("/api/sync/builds/apps/{app_id}/builds"))
375 .await;
376 assert_eq!(resp.status, 200);
377 let builds: Vec<BuildResponse> = resp.json();
378 assert_eq!(builds.len(), 1);
379 assert_eq!(builds[0].id, build.id);
380 }
381
382 #[tokio::test]
383 async fn cancel_pending_build() {
384 let mut h = TestHarness::new().await;
385 let (app_id, repo_id) = setup_authenticated(&mut h).await;
386
387 // Create config + trigger
388 let resp = h
389 .client
390 .post_json(
391 &format!("/api/sync/builds/apps/{app_id}/config"),
392 &json!({
393 "repo_id": repo_id,
394 "build_command": "make",
395 "artifact_path": "out/app",
396 "targets": ["linux/x86_64"]
397 })
398 .to_string(),
399 )
400 .await;
401 assert_eq!(resp.status, 201);
402
403 let resp = h
404 .client
405 .post_json(
406 &format!("/api/sync/builds/apps/{app_id}/trigger"),
407 &json!({ "tag": "v1.0.0" }).to_string(),
408 )
409 .await;
410 assert_eq!(resp.status, 201);
411 let build: BuildResponse = resp.json();
412
413 // Cancel
414 let resp = h
415 .client
416 .post_json(
417 &format!(
418 "/api/sync/builds/apps/{}/builds/{}/cancel",
419 app_id, build.id
420 ),
421 "{}",
422 )
423 .await;
424 assert_eq!(resp.status, 204, "Cancel failed: {}", resp.text);
425
426 // Verify status
427 let resp = h
428 .client
429 .get(&format!(
430 "/api/sync/builds/apps/{}/builds/{}",
431 app_id, build.id
432 ))
433 .await;
434 assert_eq!(resp.status, 200);
435 let cancelled: BuildResponse = resp.json();
436 assert_eq!(cancelled.status, "cancelled");
437 }
438
439 #[tokio::test]
440 async fn duplicate_active_build_rejected() {
441 let mut h = TestHarness::new().await;
442 let (app_id, repo_id) = setup_authenticated(&mut h).await;
443
444 // Create config + trigger first build
445 let resp = h
446 .client
447 .post_json(
448 &format!("/api/sync/builds/apps/{app_id}/config"),
449 &json!({
450 "repo_id": repo_id,
451 "build_command": "make",
452 "artifact_path": "out/app",
453 "targets": ["linux/x86_64"]
454 })
455 .to_string(),
456 )
457 .await;
458 assert_eq!(resp.status, 201);
459
460 let resp = h
461 .client
462 .post_json(
463 &format!("/api/sync/builds/apps/{app_id}/trigger"),
464 &json!({ "tag": "v1.0.0" }).to_string(),
465 )
466 .await;
467 assert_eq!(resp.status, 201);
468
469 // Second trigger should fail
470 let resp = h
471 .client
472 .post_json(
473 &format!("/api/sync/builds/apps/{app_id}/trigger"),
474 &json!({ "tag": "v1.0.1" }).to_string(),
475 )
476 .await;
477 assert_eq!(
478 resp.status, 400,
479 "Should reject duplicate active build: {}",
480 resp.text
481 );
482 }
483
484 #[tokio::test]
485 async fn invalid_tag_version_rejected() {
486 let mut h = TestHarness::new().await;
487 let (app_id, repo_id) = setup_authenticated(&mut h).await;
488
489 // Create config
490 let resp = h
491 .client
492 .post_json(
493 &format!("/api/sync/builds/apps/{app_id}/config"),
494 &json!({
495 "repo_id": repo_id,
496 "build_command": "make",
497 "artifact_path": "out/app",
498 "targets": ["linux/x86_64"]
499 })
500 .to_string(),
501 )
502 .await;
503 assert_eq!(resp.status, 201);
504
505 // Invalid version tag
506 let resp = h
507 .client
508 .post_json(
509 &format!("/api/sync/builds/apps/{app_id}/trigger"),
510 &json!({ "tag": "not-a-version" }).to_string(),
511 )
512 .await;
513 assert_eq!(
514 resp.status, 400,
515 "Should reject non-semver tag: {}",
516 resp.text
517 );
518
519 // Partial semver
520 let resp = h
521 .client
522 .post_json(
523 &format!("/api/sync/builds/apps/{app_id}/trigger"),
524 &json!({ "tag": "v1.2" }).to_string(),
525 )
526 .await;
527 assert_eq!(resp.status, 400, "Should reject incomplete semver tag");
528 }
529