diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java index 193ec41ed027..53c7e4229abf 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/CourseResource.java @@ -21,10 +21,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -761,7 +758,11 @@ public ResponseEntity downloadCourseArchive(@PathVariable Long courseI File zipFile = archive.toFile(); InputStreamResource resource = new InputStreamResource(new FileInputStream(zipFile)); - return ResponseEntity.ok().contentLength(zipFile.length()).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", zipFile.getName()).body(resource); + ContentDisposition contentDisposition = ContentDisposition.builder("attachment").filename(zipFile.getName()).build(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(contentDisposition); + return ResponseEntity.ok().headers(headers).contentLength(zipFile.length()).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", zipFile.getName()) + .body(resource); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java index 9f42c7385907..d3cd5fedc05b 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java @@ -24,10 +24,7 @@ import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -1150,8 +1147,12 @@ public ResponseEntity downloadExamArchive(@PathVariable Long courseId, Path archive = Path.of(examArchivesDirPath, exam.getExamArchivePath()); File zipFile = archive.toFile(); + ContentDisposition contentDisposition = ContentDisposition.builder("attachment").filename(zipFile.getName()).build(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(contentDisposition); InputStreamResource resource = new InputStreamResource(new FileInputStream(zipFile)); - return ResponseEntity.ok().contentLength(zipFile.length()).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", zipFile.getName()).body(resource); + return ResponseEntity.ok().headers(headers).contentLength(zipFile.length()).contentType(MediaType.APPLICATION_OCTET_STREAM).header("filename", zipFile.getName()) + .body(resource); } /** diff --git a/src/main/webapp/app/course/manage/course-management.service.ts b/src/main/webapp/app/course/manage/course-management.service.ts index 0639d98c2711..a95f1a978c77 100644 --- a/src/main/webapp/app/course/manage/course-management.service.ts +++ b/src/main/webapp/app/course/manage/course-management.service.ts @@ -417,11 +417,9 @@ export class CourseManagementService { * if the archive does not exist. * @param courseId The id of the course */ - downloadCourseArchive(courseId: number): Observable> { - return this.http.get(`${this.resourceUrl}/${courseId}/download-archive`, { - observe: 'response', - responseType: 'blob', - }); + downloadCourseArchive(courseId: number): void { + const url = `${this.resourceUrl}/${courseId}/download-archive`; + window.open(url, '_blank'); } /** diff --git a/src/main/webapp/app/exam/manage/exam-management.service.ts b/src/main/webapp/app/exam/manage/exam-management.service.ts index fd05d95cb618..a145093dc20f 100644 --- a/src/main/webapp/app/exam/manage/exam-management.service.ts +++ b/src/main/webapp/app/exam/manage/exam-management.service.ts @@ -468,11 +468,9 @@ export class ExamManagementService { * @param courseId * @param examId The id of the exam */ - downloadExamArchive(courseId: number, examId: number): Observable> { - return this.http.get(`${this.resourceUrl}/${courseId}/exams/${examId}/download-archive`, { - observe: 'response', - responseType: 'blob', - }); + downloadExamArchive(courseId: number, examId: number): void { + const url = `${this.resourceUrl}/${courseId}/exams/${examId}/download-archive`; + window.open(url, '_blank'); } /** diff --git a/src/main/webapp/app/shared/components/course-exam-archive-button/course-exam-archive-button.component.ts b/src/main/webapp/app/shared/components/course-exam-archive-button/course-exam-archive-button.component.ts index f24bc13c9a73..02aecadc1187 100644 --- a/src/main/webapp/app/shared/components/course-exam-archive-button/course-exam-archive-button.component.ts +++ b/src/main/webapp/app/shared/components/course-exam-archive-button/course-exam-archive-button.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { AlertService } from 'app/core/util/alert.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; @@ -9,7 +9,6 @@ import { ExamManagementService } from 'app/exam/manage/exam-management.service'; import { Course } from 'app/entities/course.model'; import { Exam } from 'app/entities/exam.model'; import dayjs from 'dayjs/esm'; -import { downloadZipFileFromResponse } from 'app/shared/util/download.util'; import { ButtonSize } from '../button.component'; import { ActionType } from 'app/shared/delete-dialog/delete-dialog.model'; import { Subject } from 'rxjs'; @@ -70,7 +69,6 @@ export class CourseExamArchiveButtonComponent implements OnInit, OnDestroy { private translateService: TranslateService, private modalService: NgbModal, private accountService: AccountService, - private changeDetectionRef: ChangeDetectorRef, ) {} ngOnInit() { @@ -127,13 +125,11 @@ export class CourseExamArchiveButtonComponent implements OnInit, OnDestroy { if (this.archiveMode === 'Exam' && this.exam) { this.examService.find(this.course.id!, this.exam.id!).subscribe((res) => { this.exam = res.body!; - this.changeDetectionRef.detectChanges(); this.displayDownloadArchiveButton = this.canDownloadArchive(); }); } else { this.courseService.find(this.course.id!).subscribe((res) => { this.course = res.body!; - this.changeDetectionRef.detectChanges(); this.displayDownloadArchiveButton = this.canDownloadArchive(); }); } @@ -181,8 +177,6 @@ export class CourseExamArchiveButtonComponent implements OnInit, OnDestroy { } if (result === 'archive' || !this.canDownloadArchive()) { this.archive(); - } else { - this.reloadCourseOrExam(); } }, () => {}, @@ -211,15 +205,9 @@ export class CourseExamArchiveButtonComponent implements OnInit, OnDestroy { downloadArchive() { if (this.archiveMode === 'Exam' && this.exam) { - this.examService.downloadExamArchive(this.course.id!, this.exam.id!).subscribe({ - next: (response) => downloadZipFileFromResponse(response), - error: () => this.alertService.error('artemisApp.courseExamArchive.archiveDownloadError'), - }); + this.examService.downloadExamArchive(this.course.id!, this.exam.id!); } else { - this.courseService.downloadCourseArchive(this.course.id!).subscribe({ - next: (response) => downloadZipFileFromResponse(response), - error: () => this.alertService.error('artemisApp.courseExamArchive.archiveDownloadError'), - }); + this.courseService.downloadCourseArchive(this.course.id!); } } diff --git a/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java b/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java index 8e547f2b3c99..6135065201d5 100644 --- a/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/course/CourseTestService.java @@ -2176,9 +2176,11 @@ public void testDownloadCourseArchiveAsInstructor_not_found() throws Exception { public void testDownloadCourseArchiveAsInstructor() throws Exception { // Archive the course and wait until it's complete Course updatedCourse = testArchiveCourseWithTestModelingAndFileUploadExercises(); + Map expectedHeaders = new HashMap<>(); + expectedHeaders.put("Content-Disposition", "attachment; filename=\"" + updatedCourse.getCourseArchivePath() + "\""); // Download the archive - var archive = request.getFile("/api/courses/" + updatedCourse.getId() + "/download-archive", HttpStatus.OK, new LinkedMultiValueMap<>()); + var archive = request.getFile("/api/courses/" + updatedCourse.getId() + "/download-archive", HttpStatus.OK, new LinkedMultiValueMap<>(), expectedHeaders); assertThat(archive).isNotNull(); assertThat(archive).exists(); assertThat(archive.getPath().length()).isGreaterThanOrEqualTo(4); diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java index f1016817f6aa..ae08e0c9a3c2 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamIntegrationTest.java @@ -1069,7 +1069,10 @@ void testDownloadExamArchiveAsInstructor() throws Exception { // Download the archive var exam = examRepository.findByCourseId(course.getId()).stream().findFirst().orElseThrow(); - var archive = request.getFile("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/download-archive", HttpStatus.OK, new LinkedMultiValueMap<>()); + Map expectedHeaders = new HashMap<>(); + expectedHeaders.put("Content-Disposition", "attachment; filename=\"" + exam.getExamArchivePath() + "\""); + var archive = request.getFile("/api/courses/" + course.getId() + "/exams/" + exam.getId() + "/download-archive", HttpStatus.OK, new LinkedMultiValueMap<>(), + expectedHeaders); assertThat(archive).isNotNull(); // Extract the archive diff --git a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java index 551f2422811b..1f3fe7254ca9 100644 --- a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java @@ -69,9 +69,9 @@ public ObjectMapper getObjectMapper() { * @param file the optional file to be sent * @param responseType the expected response type as class * @param expectedStatus the expected status + * @param the type of the main object to send + * @param the type of the response object * @return the response as object of the given type or null if the status is not 2xx - * @param the type of the main object to send - * @param the type of the response object * @throws Exception if the request fails */ public R postWithMultipartFile(String path, T paramValue, String paramName, MockMultipartFile file, Class responseType, HttpStatus expectedStatus) throws Exception { @@ -87,9 +87,9 @@ public R postWithMultipartFile(String path, T paramValue, String paramNam * @param files the optional files to be sent * @param responseType the expected response type as class * @param expectedStatus the expected status + * @param the type of the main object to send + * @param the type of the response object * @return the response as object of the given type or null if the status is not 2xx - * @param the type of the main object to send - * @param the type of the response object * @throws Exception if the request fails */ public R postWithMultipartFiles(String path, T paramValue, String paramName, List files, Class responseType, HttpStatus expectedStatus) @@ -380,9 +380,9 @@ public R postWithResponseBody(String path, T body, Class responseType) * @param responseType the expected response type as class * @param expectedStatus the expected status * @param params the optional parameters for the request + * @param the type of the main object to send + * @param the type of the response object * @return the response as object of the given type or null if the status is not 2xx - * @param the type of the main object to send - * @param the type of the response object * @throws Exception if the request fails */ public R putWithMultipartFile(String path, T paramValue, String paramName, MockMultipartFile file, Class responseType, HttpStatus expectedStatus, @@ -400,9 +400,9 @@ public R putWithMultipartFile(String path, T paramValue, String paramName * @param responseType the expected response type as class * @param expectedStatus the expected status * @param params the optional parameters for the request + * @param the type of the main object to send + * @param the type of the response object * @return the response as object of the given type or null if the status is not 2xx - * @param the type of the main object to send - * @param the type of the response object * @throws Exception if the request fails */ public R putWithMultipartFiles(String path, T paramValue, String paramName, List files, Class responseType, HttpStatus expectedStatus, @@ -558,12 +558,17 @@ public T get(String path, HttpStatus expectedStatus, Class responseType, } public File getFile(String path, HttpStatus expectedStatus, MultiValueMap params) throws Exception { + return getFile(path, expectedStatus, params, null); + } + + public File getFile(String path, HttpStatus expectedStatus, MultiValueMap params, @Nullable Map expectedResponseHeaders) throws Exception { MvcResult res = mvc.perform(MockMvcRequestBuilders.get(new URI(path)).params(params).headers(new HttpHeaders())).andExpect(status().is(expectedStatus.value())).andReturn(); restoreSecurityContext(); if (!expectedStatus.is2xxSuccessful()) { assertThat(res.getResponse().containsHeader("location")).as("no location header on failed request").isFalse(); return null; } + verifyExpectedResponseHeaders(expectedResponseHeaders, res); String tmpDirectory = System.getProperty("java.io.tmpdir"); var filename = res.getResponse().getHeader("filename"); diff --git a/src/test/javascript/spec/component/course/course-management.service.spec.ts b/src/test/javascript/spec/component/course/course-management.service.spec.ts index 50bb53271755..61cacac53048 100644 --- a/src/test/javascript/spec/component/course/course-management.service.spec.ts +++ b/src/test/javascript/spec/component/course/course-management.service.spec.ts @@ -426,15 +426,11 @@ describe('Course Management Service', () => { tick(); })); - it('should download course archive', fakeAsync(() => { - const expectedBlob = new Blob(['abc', 'cfe']); - courseManagementService.downloadCourseArchive(course.id!).subscribe((resp) => { - expect(resp.body).toEqual(expectedBlob); - }); - const req = httpMock.expectOne({ method: 'GET', url: `${resourceUrl}/${course.id}/download-archive` }); - req.flush(expectedBlob); - tick(); - })); + it('should download course archive', () => { + const windowSpy = jest.spyOn(window, 'open').mockImplementation(); + courseManagementService.downloadCourseArchive(1); + expect(windowSpy).toHaveBeenCalledWith('api/courses/1/download-archive', '_blank'); + }); it('should archive the course', fakeAsync(() => { courseManagementService.archiveCourse(course.id!).subscribe((res) => expect(res.body).toEqual(course)); diff --git a/src/test/javascript/spec/component/exam/manage/exam-management.service.spec.ts b/src/test/javascript/spec/component/exam/manage/exam-management.service.spec.ts index 8370f024633e..9f1b6c9ecc84 100644 --- a/src/test/javascript/spec/component/exam/manage/exam-management.service.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exam-management.service.spec.ts @@ -641,21 +641,11 @@ describe('Exam Management Service Tests', () => { })); it('should download the exam from archive', fakeAsync(() => { - // GIVEN const mockExam: Exam = { id: 1 }; - const mockResponse = new Blob(); - const expected = new Blob(); - - // WHEN - service.downloadExamArchive(course.id!, mockExam.id!).subscribe((res) => expect(res.body).toEqual(expected)); - // THEN - const req = httpMock.expectOne({ - method: 'GET', - url: `${service.resourceUrl}/${course.id}/exams/${mockExam.id}/download-archive`, - }); - req.flush(mockResponse); - tick(); + const windowSpy = jest.spyOn(window, 'open').mockImplementation(); + service.downloadExamArchive(course.id!, mockExam.id!); + expect(windowSpy).toHaveBeenCalledWith('api/courses/456/exams/1/download-archive', '_blank'); })); it('should archive the exam', fakeAsync(() => { diff --git a/src/test/javascript/spec/component/shared/course-exam-archive-button.component.spec.ts b/src/test/javascript/spec/component/shared/course-exam-archive-button.component.spec.ts index 287f39430d54..2dfb1b17f5f6 100644 --- a/src/test/javascript/spec/component/shared/course-exam-archive-button.component.spec.ts +++ b/src/test/javascript/spec/component/shared/course-exam-archive-button.component.spec.ts @@ -170,8 +170,7 @@ describe('Course Exam Archive Button Component', () => { })); it('should download archive for course', fakeAsync(() => { - const response: HttpResponse = new HttpResponse({ status: 200 }); - const downloadStub = jest.spyOn(courseManagementService, 'downloadCourseArchive').mockReturnValue(of(response)); + const downloadStub = jest.spyOn(courseManagementService, 'downloadCourseArchive').mockImplementation(() => {}); comp.downloadArchive(); @@ -253,14 +252,13 @@ describe('Course Exam Archive Button Component', () => { expect(comp.canCleanupCourse()).toBeFalse(); })); - it('should download archive for exam', fakeAsync(() => { - const response: HttpResponse = new HttpResponse({ status: 200 }); - const downloadStub = jest.spyOn(examManagementService, 'downloadExamArchive').mockReturnValue(of(response)); + it('should download archive for exam', () => { + const downloadStub = jest.spyOn(examManagementService, 'downloadExamArchive').mockImplementation(() => {}); comp.downloadArchive(); expect(downloadStub).toHaveBeenCalledOnce(); - })); + }); it('should archive course', fakeAsync(() => { const response: HttpResponse = new HttpResponse({ status: 200 });