Skip to main content

max / makenotwork

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