diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt index d739f49054..a2dd7ef187 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt @@ -2,6 +2,7 @@ package io.tolgee.api.v2.controllers.batch import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.component.ProjectLastModifiedManager import io.tolgee.constants.Message import io.tolgee.dtos.request.export.ExportParams import io.tolgee.exceptions.BadRequestException @@ -27,6 +28,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.WebRequest import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import java.io.InputStream import java.io.OutputStream @@ -45,38 +47,85 @@ class V2ExportController( private val languageService: LanguageService, private val authenticationFacade: AuthenticationFacade, private val streamingResponseBodyProvider: StreamingResponseBodyProvider, + private val projectLastModifiedManager: ProjectLastModifiedManager, ) { @GetMapping(value = [""]) - @Operation(summary = "Export data") + @Operation( + summary = "Export data", + description = """ + Exports project data in various formats (JSON, properties, YAML, etc.). + + ## HTTP Conditional Requests Support + + This endpoint supports HTTP conditional requests using the If-Modified-Since header: + + - **If-Modified-Since header provided**: The server checks if the project data has been modified since the specified date + - **Data not modified**: Returns HTTP 304 Not Modified with empty body + - **Data modified or no header**: Returns HTTP 200 OK with the exported data and Last-Modified header + + The Last-Modified header in the response contains the timestamp of the last project modification, + which can be used for subsequent conditional requests to avoid unnecessary data transfer when the + project hasn't changed. + + Cache-Control header is set to max-age=0 to ensure validation on each request. + """ + ) @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @AllowApiAccess @ExportApiResponse fun exportData( @ParameterObject params: ExportParams, - ): ResponseEntity { - params.languages = - languageService - .getLanguagesForExport(params.languages, projectHolder.project.id, authenticationFacade.authenticatedUser.id) - .toList() - .map { language -> language.tag } - .toSet() - val exported = exportService.export(projectHolder.project.id, params) - checkExportNotEmpty(exported) - return getExportResponse(params, exported) + request: WebRequest + ): ResponseEntity? { + return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { headersBuilder -> + params.languages = + languageService + .getLanguagesForExport(params.languages, projectHolder.project.id, authenticationFacade.authenticatedUser.id) + .toList() + .map { language -> language.tag } + .toSet() + val exported = exportService.export(projectHolder.project.id, params) + checkExportNotEmpty(exported) + val responseEntity = getExportResponse(params, exported) + headersBuilder.headers(responseEntity.headers) + responseEntity.body + } } @PostMapping(value = [""]) @Operation( summary = "Export data (post)", - description = """Exports data (post). Useful when exceeding allowed URL size.""", + description = """ + Exports project data in various formats (JSON, properties, YAML, etc.). + Useful when exceeding allowed URL size with GET requests. + + ## HTTP Conditional Requests Support + + This endpoint supports HTTP conditional requests using the If-Modified-Since header: + + - **If-Modified-Since header provided**: The server checks if the project data has been modified since the specified date + - **Data not modified**: Returns HTTP 412 Precondition Failed with empty body (as per HTTP specification for POST requests) + - **Data modified or no header**: Returns HTTP 200 OK with the exported data and Last-Modified header + + Note: Unlike GET requests which return 304 Not Modified, POST requests return 412 Precondition Failed + when the If-Modified-Since condition is not met, as POST is considered a modifying method according + to HTTP specifications. + + The Last-Modified header in the response contains the timestamp of the last project modification, + which can be used for subsequent conditional requests to avoid unnecessary data transfer when the + project hasn't changed. + + Cache-Control header is set to max-age=0 to ensure validation on each request. + """, ) @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @AllowApiAccess @ExportApiResponse fun exportPost( @RequestBody params: ExportParams, - ): ResponseEntity { - return exportData(params) + request: WebRequest + ): ResponseEntity? { + return exportData(params, request) } private fun getZipHeaders(projectName: String): HttpHeaders { diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt index 8fb68764b9..cc07fa5180 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt @@ -15,6 +15,7 @@ import io.tolgee.activity.ActivityService import io.tolgee.activity.RequestActivity import io.tolgee.activity.data.ActivityType import io.tolgee.api.v2.controllers.IController +import io.tolgee.component.ProjectLastModifiedManager import io.tolgee.component.ProjectTranslationLastModifiedManager import io.tolgee.constants.Message import io.tolgee.dtos.queryResults.TranslationHistoryView @@ -57,7 +58,6 @@ import org.springframework.data.domain.Sort import org.springframework.data.web.PagedResourcesAssembler import org.springframework.data.web.SortDefault import org.springframework.hateoas.PagedModel -import org.springframework.http.CacheControl import org.springframework.http.ResponseEntity import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.WebDataBinder @@ -73,7 +73,6 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import org.springframework.web.context.request.WebRequest -import java.util.concurrent.TimeUnit @Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection") @RestController @@ -106,6 +105,7 @@ class TranslationsController( private val createOrUpdateTranslationsFacade: CreateOrUpdateTranslationsFacade, private val taskService: ITaskService, private val translationSuggestionService: TranslationSuggestionService, + private val projectLastModifiedManager: ProjectLastModifiedManager, ) : IController { @GetMapping(value = ["/{languages}"]) @Operation( @@ -162,17 +162,11 @@ When null, resulting file will be a flat key-value object. filterTag: List? = null, request: WebRequest, ): ResponseEntity>? { - val lastModified: Long = projectTranslationLastModifiedManager.getLastModified(projectHolder.project.id) + return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { + val permittedTags = + securityService + .filterViewPermissionByTag(projectId = projectHolder.project.id, languageTags = languages) - if (request.checkNotModified(lastModified)) { - return null - } - - val permittedTags = - securityService - .filterViewPermissionByTag(projectId = projectHolder.project.id, languageTags = languages) - - val response = translationService.getTranslations( languageTags = permittedTags, namespace = ns, @@ -180,13 +174,7 @@ When null, resulting file will be a flat key-value object. structureDelimiter = request.getStructureDelimiter(), filterTag = filterTag, ) - - return ResponseEntity.ok() - .lastModified(lastModified) - .cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)) - .body( - response, - ) + } } @PutMapping("") diff --git a/backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt b/backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt new file mode 100644 index 0000000000..a42f12e3df --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt @@ -0,0 +1,72 @@ +package io.tolgee.component + +import io.tolgee.security.ProjectHolder +import org.springframework.http.CacheControl +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component +import org.springframework.web.context.request.WebRequest +import java.util.concurrent.TimeUnit + +/** + * Component responsible for managing HTTP conditional requests based on project data modifications. + * + * This manager implements the HTTP conditional request mechanism (If-Modified-Since/Last-Modified headers) + * to enable efficient caching of project-related data. It helps reduce unnecessary data transfer and + * processing by allowing clients to cache responses and only receive new data when the project has + * actually been modified. + */ +@Component +class ProjectLastModifiedManager( + private val projectTranslationLastModifiedManager: ProjectTranslationLastModifiedManager, + private val projectHolder: ProjectHolder +) { + /** + * Executes a function only when the project data has been modified since the client's last request. + * + * This method implements HTTP conditional request handling by: + * 1. Retrieving the last modification timestamp of the current project + * 2. Checking if the client's If-Modified-Since header indicates the data is still current + * 3. If data hasn't changed, returning null (which translates to HTTP 304 Not Modified) + * 4. If data has changed, executing the provided function and wrapping the result in a ResponseEntity + * with appropriate cache control headers + * + * The response includes: + * - Last-Modified header set to the project's modification timestamp + * - Cache-Control header set to max-age=0 to ensure validation on each request + * + * This mechanism helps optimize performance by preventing export data computation and loading from database when + * not modified. + * + */ + fun onlyWhenProjectDataChanged( + request: WebRequest, + fn: ( + /** + * Enables setting of additional headers on the response. + */ + headersBuilder: ResponseEntity.HeadersBuilder<*> + ) -> T? + ): ResponseEntity? { + val lastModified: Long = projectTranslationLastModifiedManager.getLastModified(projectHolder.project.id) + + if (request.checkNotModified(lastModified)) { + return null + } + + val headersBuilder = ResponseEntity + .ok() + .lastModified(lastModified) + .cacheControl(DEFAULT_CACHE_CONTROL_HEADER) + + val response = fn(headersBuilder) + + return headersBuilder + .body( + response, + ) + } + + companion object { + val DEFAULT_CACHE_CONTROL_HEADER = CacheControl.maxAge(0, TimeUnit.SECONDS) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt index 50cb6ef9c5..15d406fe0b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.api.v2.controllers.IController +import io.tolgee.component.ProjectLastModifiedManager import io.tolgee.model.enums.Scope import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess @@ -15,6 +16,7 @@ import io.tolgee.util.StreamingResponseBodyProvider import org.apache.tomcat.util.http.fileupload.IOUtils import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import org.springframework.web.context.request.WebRequest import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import java.io.ByteArrayInputStream import java.io.OutputStream @@ -36,6 +38,7 @@ class ExportController( private val projectHolder: ProjectHolder, private val authenticationFacade: AuthenticationFacade, private val streamingResponseBodyProvider: StreamingResponseBodyProvider, + private val projectLastModifiedManager: ProjectLastModifiedManager, ) : IController { @GetMapping(value = ["/jsonZip"], produces = ["application/zip"]) @Operation(summary = "Export to ZIP of jsons", description = "Exports data as ZIP of jsons", deprecated = true) @@ -44,39 +47,39 @@ class ExportController( @Deprecated("Use v2 export controller") fun doExportJsonZip( @PathVariable("projectId") projectId: Long?, - ): ResponseEntity { - val allLanguages = - permissionService.getPermittedViewLanguages( - projectHolder.project.id, - authenticationFacade.authenticatedUser.id, - ) + request: WebRequest + ): ResponseEntity? { + return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { headersBuilder -> + val allLanguages = + permissionService.getPermittedViewLanguages( + projectHolder.project.id, + authenticationFacade.authenticatedUser.id, + ) - return ResponseEntity - .ok() - .header( + headersBuilder.header( "Content-Disposition", String.format("attachment; filename=\"%s.zip\"", projectHolder.project.name), ) - .body( - streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream -> - val zipOutputStream = ZipOutputStream(out) - val translations = - translationService.getTranslations( - allLanguages.map { it.tag }.toSet(), - null, - projectHolder.project.id, - '.', - ) - for ((key, value) in translations) { - zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key))) - val data = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(value) - val byteArrayInputStream = ByteArrayInputStream(data) - IOUtils.copy(byteArrayInputStream, zipOutputStream) - byteArrayInputStream.close() - zipOutputStream.closeEntry() - } - zipOutputStream.close() - }, - ) + + streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream -> + val zipOutputStream = ZipOutputStream(out) + val translations = + translationService.getTranslations( + allLanguages.map { it.tag }.toSet(), + null, + projectHolder.project.id, + '.', + ) + for ((key, value) in translations) { + zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key))) + val data = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(value) + val byteArrayInputStream = ByteArrayInputStream(data) + IOUtils.copy(byteArrayInputStream, zipOutputStream) + byteArrayInputStream.close() + zipOutputStream.closeEntry() + } + zipOutputStream.close() + } + } } } diff --git a/backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt b/backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt index 5770fb955e..030b70d010 100644 --- a/backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt +++ b/backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt @@ -1,8 +1,7 @@ package io.tolgee.websocket -import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.dtos.cacheable.ApiKeyDto import io.tolgee.model.enums.Scope -import io.tolgee.security.authentication.JwtService import io.tolgee.security.authentication.TolgeeAuthentication import io.tolgee.service.security.SecurityService import org.springframework.context.annotation.Configuration @@ -23,10 +22,10 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo @Configuration @EnableWebSocketMessageBroker class WebSocketConfig( - @Lazy - private val jwtService: JwtService, @Lazy private val securityService: SecurityService, + @Lazy + private val websocketAuthenticationResolver: WebsocketAuthenticationResolver, ) : WebSocketMessageBrokerConfigurer { override fun configureMessageBroker(config: MessageBrokerRegistry) { config.enableSimpleBroker("/") @@ -46,15 +45,17 @@ class WebSocketConfig( val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java) if (accessor?.command == StompCommand.CONNECT) { - val tokenString = accessor.getNativeHeader("jwtToken")?.firstOrNull() - accessor.user = if (tokenString == null) null else jwtService.validateToken(tokenString) + val authorization = accessor.getNativeHeader("authorization")?.firstOrNull() + val xApiKey = accessor.getNativeHeader("x-api-key")?.firstOrNull() + val legacyJwt = accessor.getNativeHeader("jwtToken")?.firstOrNull() + accessor.user = websocketAuthenticationResolver.resolve(authorization, xApiKey, legacyJwt) } - val user = (accessor?.user as? TolgeeAuthentication)?.principal + val authentication = accessor?.user as? TolgeeAuthentication if (accessor?.command == StompCommand.SUBSCRIBE) { - checkProjectPathPermissions(user, accessor.destination) - checkUserPathPermissions(user, accessor.destination) + checkProjectPathPermissionsAuth(authentication, accessor.destination) + checkUserPathPermissionsAuth(authentication, accessor.destination) } return message @@ -63,8 +64,8 @@ class WebSocketConfig( ) } - fun checkProjectPathPermissions( - user: UserAccountDto?, + fun checkProjectPathPermissionsAuth( + authentication: TolgeeAuthentication?, destination: String?, ) { val projectId = @@ -73,10 +74,21 @@ class WebSocketConfig( ?.getOrNull(1)?.toLong() } ?: return - if (user == null) { + if (authentication == null) { throw MessagingException("Unauthenticated") } + val creds = authentication.credentials + if (creds is ApiKeyDto) { + val matchesProject = creds.projectId == projectId + val hasScope = creds.scopes.contains(Scope.KEYS_VIEW) + if (!matchesProject || !hasScope) { + throw MessagingException("Forbidden") + } + return + } + + val user = authentication.principal try { securityService.checkProjectPermissionNoApiKey(projectId = projectId, Scope.KEYS_VIEW, user) } catch (e: Exception) { @@ -84,8 +96,8 @@ class WebSocketConfig( } } - fun checkUserPathPermissions( - user: UserAccountDto?, + fun checkUserPathPermissionsAuth( + authentication: TolgeeAuthentication?, destination: String?, ) { val userId = @@ -94,6 +106,17 @@ class WebSocketConfig( ?.getOrNull(1)?.toLong() } ?: return + if (authentication == null) { + throw MessagingException("Forbidden") + } + + val creds = authentication.credentials + if (creds is ApiKeyDto) { + // API keys must not subscribe to user topics + throw MessagingException("Forbidden") + } + + val user = (authentication as? TolgeeAuthentication)?.principal if (user?.id != userId) { throw MessagingException("Forbidden") } diff --git a/backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt b/backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt new file mode 100644 index 0000000000..40589ce46e --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt @@ -0,0 +1,104 @@ +package io.tolgee.websocket + +import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.security.PAT_PREFIX +import io.tolgee.security.authentication.JwtService +import io.tolgee.security.authentication.TolgeeAuthentication +import io.tolgee.security.authentication.TolgeeAuthenticationDetails +import io.tolgee.service.security.ApiKeyService +import io.tolgee.service.security.PatService +import io.tolgee.service.security.UserAccountService +import org.springframework.context.annotation.Lazy +import org.springframework.stereotype.Component + +@Component +class WebsocketAuthenticationResolver( + @Lazy private val jwtService: JwtService, + @Lazy private val apiKeyService: ApiKeyService, + @Lazy private val patService: PatService, + @Lazy private val userAccountService: UserAccountService, +) { + /** + * Resolves STOMP CONNECT headers into TolgeeAuthentication. + * Supports: + * - Authorization: Bearer + * - X-API-Key: tgpat_ (PAT) or tgpak_<...> (PAK, incl. legacy/raw) + * - jwtToken: (legacy header) + */ + fun resolve( + authorizationHeader: String?, + xApiKeyHeader: String?, + legacyJwtHeader: String?, + ): TolgeeAuthentication? { + // Authorization: Bearer + val bearer = extractBearer(authorizationHeader) + if (bearer != null) { + return runCatching { jwtService.validateToken(bearer) }.getOrNull() + } + + // X-API-Key: PAT / PAK + val xApiKey = xApiKeyHeader + if (!xApiKey.isNullOrBlank()) { + return when { + xApiKey.startsWith(PAT_PREFIX) -> runCatching { patAuth(xApiKey) }.getOrNull() + else -> runCatching { pakAuth(xApiKey) }.getOrNull() + } + } + + // Legacy jwtToken header + if (!legacyJwtHeader.isNullOrBlank()) { + return runCatching { jwtService.validateToken(legacyJwtHeader) }.getOrNull() + } + + return null + } + + private fun extractBearer(value: String?): String? { + if (value == null) return null + val prefix = "Bearer " + return if (value.startsWith(prefix, ignoreCase = true)) value.substring(prefix.length).trim() else null + } + + private fun pakAuth(key: String): TolgeeAuthentication { + val parsed = apiKeyService.parseApiKey(key) ?: throw AuthenticationException(Message.INVALID_PROJECT_API_KEY) + val hash = apiKeyService.hashKey(parsed) + val pak = apiKeyService.findDto(hash) ?: throw AuthenticationException(Message.INVALID_PROJECT_API_KEY) + + if (pak.expiresAt?.before(java.util.Date()) == true) { + throw AuthenticationException(Message.PROJECT_API_KEY_EXPIRED) + } + + val userAccount: UserAccountDto = + userAccountService.findDto(pak.userAccountId) ?: throw AuthenticationException(Message.USER_NOT_FOUND) + + apiKeyService.updateLastUsedAsync(pak.id) + + return TolgeeAuthentication( + pak, + userAccount, + TolgeeAuthenticationDetails(false), + ) + } + + private fun patAuth(key: String): TolgeeAuthentication { + val hash = patService.hashToken(key.substring(PAT_PREFIX.length)) + val pat = patService.findDto(hash) ?: throw AuthenticationException(Message.INVALID_PAT) + + if (pat.expiresAt?.before(java.util.Date()) == true) { + throw AuthenticationException(Message.PAT_EXPIRED) + } + + val userAccount: UserAccountDto = + userAccountService.findDto(pat.userAccountId) ?: throw AuthenticationException(Message.USER_NOT_FOUND) + + patService.updateLastUsedAsync(pat.id) + + return TolgeeAuthentication( + pat, + userAccount, + TolgeeAuthenticationDetails(false), + ) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportAllFormatsTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt similarity index 86% rename from backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportAllFormatsTest.kt rename to backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt index ff4cb12d0a..cacd6eb597 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportAllFormatsTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt @@ -1,4 +1,4 @@ -package io.tolgee.api.v2.controllers +package io.tolgee.api.v2.controllers.v2ExportController import io.tolgee.ProjectAuthControllerTest import io.tolgee.development.testDataBuilder.data.NamespacesTestData @@ -6,7 +6,7 @@ import io.tolgee.fixtures.andIsOk import io.tolgee.formats.ExportFormat import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod -import io.tolgee.testing.assertions.Assertions.assertThat +import org.assertj.core.api.Assertions import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.params.ParameterizedTest @@ -48,24 +48,24 @@ class V2ExportAllFormatsTest : ProjectAuthControllerTest("/v2/projects/") { // Verify we get some content back val responseContent = mvcResult.response.contentAsByteArray - assertThat(responseContent).isNotEmpty() + Assertions.assertThat(responseContent).isNotEmpty() // For ZIP responses, verify we can parse the content if (mvcResult.response.contentType?.contains("zip") == true) { val parsedFiles = parseZip(responseContent) - assertThat(parsedFiles).isNotEmpty() + Assertions.assertThat(parsedFiles).isNotEmpty() // Verify we have files for both the default namespace and ns-1 val fileNames = parsedFiles.keys - assertThat(fileNames).hasSizeGreaterThan(0) + Assertions.assertThat(fileNames).hasSizeGreaterThan(0) // For formats that support namespaces, verify namespace structure if (!format.multiLanguage) { - assertThat(fileNames.any { it.contains("ns-1") || it.contains("en.") }).isTrue() + Assertions.assertThat(fileNames.any { it.contains("ns-1") || it.contains("en.") }).isTrue() } } else { // For single file responses, verify we have content - assertThat(responseContent.size).isGreaterThan(0) + Assertions.assertThat(responseContent.size).isGreaterThan(0) } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt new file mode 100644 index 0000000000..1f08c1c8b2 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt @@ -0,0 +1,138 @@ +package io.tolgee.api.v2.controllers.v2ExportController + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.TranslationsTestData +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.retry +import io.tolgee.model.enums.Scope +import io.tolgee.testing.ContextRecreatingTest +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.transaction.annotation.Transactional + +@ContextRecreatingTest +@SpringBootTest( + properties = [ + "tolgee.cache.enabled=true", + ], +) +class V2ExportControllerCachingTest : ProjectAuthControllerTest("/v2/projects/") { + var testData: TranslationsTestData? = null + + @BeforeEach + fun setup() { + clearCaches() + } + + @AfterEach + fun tearDown() { + clearForcedDate() + } + + private fun initBaseData() { + testData = TranslationsTestData() + testDataService.saveTestData(testData!!.root) + prepareUserAndProject(testData!!) + } + + private fun prepareUserAndProject(testData: TranslationsTestData) { + userAccount = testData.user + projectSupplier = { testData.project } + } + + @Test + @Transactional + @ProjectJWTAuthTestMethod + fun `returns 304 for POST export when data not modified`() { + retryingOnCommonIssues { + initBaseData() + + // First request - should return data + val firstResponse = performProjectAuthGet("export?languages=en&zip=false") + .andIsOk + .andReturn() + + val lastModifiedHeader = firstResponse.response.getHeaderValue("Last-Modified") as String + Assertions.assertThat(lastModifiedHeader).isNotNull() + + // Second request with If-Modified-Since header - should return 304 + val headers = org.springframework.http.HttpHeaders() + headers["If-Modified-Since"] = lastModifiedHeader + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + val secondResponse = performGet("/v2/projects/${project.id}/export?languages=en&zip=false", headers).andReturn() + + Assertions.assertThat(secondResponse.response.status).isEqualTo(304) + Assertions.assertThat(secondResponse.response.contentAsByteArray).isEmpty() + Assertions.assertThat(secondResponse.response.contentAsString).isEmpty() + } + } + + @Test + @Transactional + @ProjectJWTAuthTestMethod + fun `returns 412 for POST export when data not modified`() { + retryingOnCommonIssues { + initBaseData() + + // First request - should return data + val firstResponse = performProjectAuthPost("export", mapOf("languages" to setOf("en"), "zip" to false)) + .andIsOk + .andReturn() + + val lastModifiedHeader = firstResponse.response.getHeaderValue("Last-Modified") as String + Assertions.assertThat(lastModifiedHeader).isNotNull() + + // Second request with If-Modified-Since header - should return 304 + val headers = org.springframework.http.HttpHeaders() + headers["If-Modified-Since"] = lastModifiedHeader + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + val secondResponse = performPost( + "/v2/projects/${project.id}/export", + mapOf( + "languages" to setOf("en"), + "zip" to false + ), + headers + ).andReturn() + + // Since this is POST request Spring returns 412 as it is according to the spec for modifying methods. + // In our case, we are using POST only since we cannot provide all the params in the query. + Assertions.assertThat(secondResponse.response.status).isEqualTo(412) + Assertions.assertThat(secondResponse.response.contentAsByteArray).isEmpty() + Assertions.assertThat(secondResponse.response.contentAsString).isEmpty() + } + } + + private fun retryingOnCommonIssues(fn: () -> Unit) { + retry( + retries = 10, + exceptionMatcher = matcher@{ + if (it is ConcurrentModificationException || + it is DataIntegrityViolationException || + it is NullPointerException + ) { + return@matcher true + } + + if (it is IllegalStateException && it.message?.contains("End size") == true) { + return@matcher true + } + + false + }, + ) { + try { + fn() + } finally { + executeInNewTransaction { + testData?.let { testDataService.cleanTestData(it.root) } + } + } + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt similarity index 94% rename from backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportControllerTest.kt rename to backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt index ab7e0fc8c1..c01c4175a0 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt @@ -1,4 +1,4 @@ -package io.tolgee.api.v2.controllers +package io.tolgee.api.v2.controllers.v2ExportController import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue @@ -20,10 +20,10 @@ import io.tolgee.fixtures.waitForNotThrowing import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert -import io.tolgee.testing.assertions.Assertions.assertThat import io.tolgee.util.addDays import io.tolgee.util.addSeconds import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.assertj.core.api.Assertions import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -34,8 +34,8 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.dao.DataIntegrityViolationException +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MvcResult import org.springframework.transaction.annotation.Transactional import java.io.ByteArrayInputStream @@ -53,7 +53,7 @@ class V2ExportControllerTest : ProjectAuthControllerTest("/v2/projects/") { var namespacesTestData: NamespacesTestData? = null var languagePermissionsTestData: LanguagePermissionsTestData? = null - @MockBean + @MockitoBean @Autowired lateinit var postHog: PostHog @@ -123,9 +123,9 @@ class V2ExportControllerTest : ProjectAuthControllerTest("/v2/projects/") { response.andPrettyPrint.andAssertThatJson { node("Z key").isEqualTo("A translation") } - assertThat(response.andReturn().response.getHeaderValue("content-type")) + Assertions.assertThat(response.andReturn().response.getHeaderValue("content-type")) .isEqualTo("application/json") - assertThat(response.andReturn().response.getHeaderValue("content-disposition")) + Assertions.assertThat(response.andReturn().response.getHeaderValue("content-disposition")) .isEqualTo("""attachment; filename="en.json"""") } } @@ -142,9 +142,9 @@ class V2ExportControllerTest : ProjectAuthControllerTest("/v2/projects/") { performProjectAuthGet("export?languages=en&zip=false&format=XLIFF") .andDo { obj: MvcResult -> obj.getAsyncResult(30000) } - assertThat(response.andReturn().response.getHeaderValue("content-type")) + Assertions.assertThat(response.andReturn().response.getHeaderValue("content-type")) .isEqualTo("application/x-xliff+xml") - assertThat(response.andReturn().response.getHeaderValue("content-disposition")) + Assertions.assertThat(response.andReturn().response.getHeaderValue("content-disposition")) .isEqualTo("""attachment; filename="en.xliff"""") } } @@ -175,7 +175,7 @@ class V2ExportControllerTest : ProjectAuthControllerTest("/v2/projects/") { } } - assertThat(time).isLessThan(2000) + Assertions.assertThat(time).isLessThan(2000) } } diff --git a/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt b/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt index 5228abee9f..fab139f1de 100644 --- a/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt +++ b/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt @@ -261,7 +261,7 @@ class BatchJobTestUtil( websocketHelper = WebsocketTestHelper( port, - jwtService.emitToken(testData.user.id), + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(testData.user.id)), testData.projectBuilder.self.id, testData.user.id, ) diff --git a/backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt index 831ab8f98c..aa7a7cc4a2 100644 --- a/backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt @@ -2,31 +2,47 @@ package io.tolgee.controllers import io.tolgee.ProjectAuthControllerTest import io.tolgee.development.testDataBuilder.data.LanguagePermissionsTestData +import io.tolgee.development.testDataBuilder.data.TranslationsTestData import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotModified import io.tolgee.fixtures.andIsOk import io.tolgee.model.Language import io.tolgee.model.enums.Scope import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.springframework.http.HttpHeaders import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.result.MockMvcResultMatchers import org.springframework.transaction.annotation.Transactional import java.io.ByteArrayInputStream +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.* import java.util.function.Consumer import java.util.zip.ZipEntry import java.util.zip.ZipInputStream class ExportControllerTest : ProjectAuthControllerTest() { + private lateinit var testData: TranslationsTestData + + @BeforeEach + fun setup() { + testData = TranslationsTestData() + testDataService.saveTestData(testData.root) + projectSupplier = { testData.project } + userAccount = testData.user + } + @Test @Transactional @ProjectJWTAuthTestMethod fun exportZipJson() { - val base = dbPopulator.populate() - commitTransaction() - projectSupplier = { base.project } - userAccount = base.userAccount val mvcResult = performProjectAuthGet("export/jsonZip") .andIsOk.andDo { obj: MvcResult -> obj.getAsyncResult(60000) }.andReturn() @@ -44,9 +60,6 @@ class ExportControllerTest : ProjectAuthControllerTest() { @Transactional @ProjectApiKeyAuthTestMethod fun exportZipJsonWithApiKey() { - val base = dbPopulator.populate() - commitTransaction() - projectSupplier = { base.project } val mvcResult = performProjectAuthGet("export/jsonZip") .andExpect(MockMvcResultMatchers.status().isOk).andDo { obj: MvcResult -> obj.asyncResult }.andReturn() @@ -80,6 +93,51 @@ class ExportControllerTest : ProjectAuthControllerTest() { Assertions.assertThat(fileSizes).containsOnlyKeys("en.json") } + @Test + @ProjectJWTAuthTestMethod + fun `returns export with last modified header`() { + val now = Date() + setForcedDate(now) + val lastModified = performAndGetLastModified() + assertEqualsDate(lastModified, now) + } + + @Test + @ProjectJWTAuthTestMethod + fun `returns 304 when export not modified`() { + val now = Date() + setForcedDate(now) + val lastModified = performAndGetLastModified() + performWithIfModifiedSince(lastModified).andIsNotModified + } + + @AfterEach + fun clearDate() { + clearForcedDate() + testDataService.cleanTestData(testData.root) + } + + private fun performWithIfModifiedSince(lastModified: String?): ResultActions { + val headers = HttpHeaders() + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + headers["If-Modified-Since"] = lastModified + return performGet("/api/project/export/jsonZip", headers) + } + + private fun performAndGetLastModified(): String? = + performProjectAuthGet("export/jsonZip") + .andIsOk.lastModified() + + private fun ResultActions.lastModified() = this.andReturn().response.getHeader("Last-Modified") + + private fun assertEqualsDate( + lastModified: String?, + now: Date, + ) { + val zdt: ZonedDateTime = ZonedDateTime.parse(lastModified, DateTimeFormatter.RFC_1123_DATE_TIME) + (zdt.toInstant().toEpochMilli() / 1000).assert.isEqualTo(now.time / 1000) + } + private fun parseZip(responseContent: ByteArray): Map { val byteArrayInputStream = ByteArrayInputStream(responseContent) val zipInputStream = ZipInputStream(byteArrayInputStream) diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt index b84164254a..7937a334d6 100644 --- a/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt @@ -45,14 +45,14 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" currentUserWebsocket = WebsocketTestHelper( port, - jwtService.emitToken(testData.user.id), + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(testData.user.id)), testData.projectBuilder.self.id, testData.user.id, ) anotherUserWebsocket = WebsocketTestHelper( port, - jwtService.emitToken(anotherUser.id), + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(anotherUser.id)), testData.projectBuilder.self.id, anotherUser.id, ) @@ -239,12 +239,13 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" val spyingUserWebsocket = WebsocketTestHelper( port, - jwtService.emitToken(anotherUser.id), + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(anotherUser.id)), testData.projectBuilder.self.id, // anotherUser trying to spy on other user's websocket testData.user.id, ) spyingUserWebsocket.listenForNotificationsChanged() + spyingUserWebsocket.waitForForbidden() saveNotificationForCurrentUser() assertCurrentUserReceivedMessage() diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt new file mode 100644 index 0000000000..3560fcf2f7 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt @@ -0,0 +1,218 @@ +package io.tolgee.websocket + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.BaseTestData +import io.tolgee.dtos.request.key.CreateKeyDto +import io.tolgee.fixtures.andIsCreated +import io.tolgee.model.Pat +import io.tolgee.model.enums.Scope +import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.util.addMinutes +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import java.util.Date + +@SpringBootTest( + properties = [ + "tolgee.websocket.use-redis=false", + ], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class WebsocketAuthenticationTest : ProjectAuthControllerTest() { + lateinit var testData: BaseTestData + + @LocalServerPort + private val port: Int? = null + + @BeforeEach + fun before() { + testData = BaseTestData() + } + + @Test + @ProjectJWTAuthTestMethod + fun `works with JWT`() { + saveTestData() + testItWorksWithAuth( + auth = + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(testData.user.id)) + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with invalid JWT`() { + saveTestData() + testItIsUnauthenticatedWithAuth( + auth = + WebsocketTestHelper.Auth(jwtToken = "invalid") + ) + } + + // we need at least keys.view permission when using JWT + @Test + @ProjectJWTAuthTestMethod +fun `forbidden with insufficient scopes on user with JWT`() { + val user2 = testData.root.addUserAccount { username = "user2" } + saveTestData() + testItIsForbiddenWithAuth( + auth = WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(user2.self.id)) + ) + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `works with PAK`() { + saveTestData() + testItWorksWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = apiKey.key) + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with invalid PAK`() { + saveTestData() + testItIsUnauthenticatedWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = "invalid-api-key") + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with expired PAK`() { + saveTestData() + // Create an expired API key by manipulating date + val expiredApiKey = apiKeyService.create( + userAccount = testData.user, + scopes = setOf(Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW), + project = testData.projectBuilder.self, + expiresAt = currentDateProvider.date.addMinutes(-60).time + ) + + testItIsUnauthenticatedWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = expiredApiKey.key) + ) + } + + /** for api key we need at least translations.view scope */ + @Test + @ProjectApiKeyAuthTestMethod(scopes = []) // No scopes +fun `forbidden with insufficient scopes on PAT`() { + saveTestData() + testItIsForbiddenWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = apiKey.key) + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `works with PAT token`() { + val pat = addPatToTestData( + expiresAt = currentDateProvider.date.addMinutes(60) + ) + saveTestData() + testItWorksWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = pat.tokenWithPrefix) + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with invalid PAT`() { + saveTestData() + testItIsUnauthenticatedWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = "tgpat_invalid") + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with expired PAT`() { + val expiredPat = addPatToTestData( + expiresAt = currentDateProvider.date.addMinutes(-60) + ) + saveTestData() + testItIsUnauthenticatedWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = expiredPat.tokenWithPrefix) + ) + } + + // we need at least keys.view permission when using PAT + @Test + @ProjectJWTAuthTestMethod +fun `forbidden with insufficient scopes on user with PAT`() { + val pat = addInsufficientPatToTestData() + saveTestData() + // This test should fail with insufficient permissions - intentionally designed to fail + testItIsForbiddenWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = pat.tokenWithPrefix) + ) + } + + private fun saveTestData() { + testDataService.saveTestData(testData.root) + userAccount = testData.user + projectSupplier = { testData.projectBuilder.self } + } + + fun testItWorksWithAuth(auth: WebsocketTestHelper.Auth) { + val socket = prepareSocket(auth) + socket.assertNotified( + { createKey() }, + { + assertThatJson(it.poll()).node("data").isObject + } + ) + } + + fun testItIsForbiddenWithAuth(auth: WebsocketTestHelper.Auth) { + val socket = prepareSocket(auth) + socket.waitForForbidden() + } + + fun testItIsUnauthenticatedWithAuth(auth: WebsocketTestHelper.Auth) { + val socket = prepareSocket(auth) + socket.waitForUnauthenticated() + } + + private fun prepareSocket(auth: WebsocketTestHelper.Auth): WebsocketTestHelper { + val socket = WebsocketTestHelper( + port, + auth, + testData.projectBuilder.self.id, + testData.user.id, + ) + + socket.listenForTranslationDataModified() + return socket + } + + fun createKey() { + performAuthPost("/v2/projects/${project.id}/keys", CreateKeyDto("test_key")) + .andIsCreated + } + + private fun addPatToTestData(expiresAt: Date): Pat { + return testData.userAccountBuilder.addPat { + description = "Test" + this.expiresAt = expiresAt + }.self + } + + private fun addInsufficientPatToTestData(): Pat { + val user = testData.root.addUserAccount { + username = "user2" + } + return user.addPat { + description = "Test" + this.expiresAt = currentDateProvider.date.addMinutes(60) + }.self + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt index 7a64141626..c8e0d6f65d 100644 --- a/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt @@ -1,5 +1,6 @@ package io.tolgee.websocket +import io.tolgee.fixtures.WaitNotSatisfiedException import io.tolgee.fixtures.waitFor import io.tolgee.util.Logging import io.tolgee.util.logger @@ -18,7 +19,7 @@ import java.lang.reflect.Type import java.util.concurrent.LinkedBlockingDeque import java.util.concurrent.TimeUnit -class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: Long, val userId: Long) : Logging { +class WebsocketTestHelper(val port: Int?, val auth: Auth, val projectId: Long, val userId: Long) : Logging { private var sessionHandler: MySessionHandler? = null lateinit var receivedMessages: LinkedBlockingDeque @@ -51,11 +52,20 @@ class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: L webSocketStompClient.connectAsync( "http://localhost:$port/websocket", WebSocketHttpHeaders(), - StompHeaders().apply { add("jwtToken", jwtToken) }, + getAuthHeaders(), sessionHandler!!, ).get(10, TimeUnit.SECONDS) } + private fun getAuthHeaders(): StompHeaders { + return StompHeaders().apply { + when { + auth.jwtToken != null -> add("jwtToken", auth.jwtToken) + auth.apiKey != null -> add("x-api-key", auth.apiKey) + } + } + } + fun stop() { logger.info("Stopping websocket listener") try { @@ -68,11 +78,17 @@ class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: L logger.info("Stopped websocket listener") } - private class MySessionHandler( + class MySessionHandler( val dest: String, val receivedMessages: LinkedBlockingDeque, ) : StompSessionHandlerAdapter(), Logging { var subscription: StompSession.Subscription? = null + var authenticationStatus: AuthenticationStatus? = null + + enum class AuthenticationStatus { + UNAUTHENTICATED, + FORBIDDEN + } override fun afterConnected( session: StompSession, @@ -109,6 +125,9 @@ class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: L stompHeaders: StompHeaders, o: Any?, ) { + handleForbidden(stompHeaders) + handleUnauthenticated(stompHeaders) + logger.info( "Handle Frame with stompHeaders: '{}' and payload: '{}'", stompHeaders, @@ -126,6 +145,18 @@ class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: L throw RuntimeException(e) } } + + private fun handleForbidden(stompHeaders: StompHeaders) { + if (stompHeaders.get("message")?.single() == "Forbidden") { + authenticationStatus = AuthenticationStatus.FORBIDDEN + } + } + + private fun handleUnauthenticated(stompHeaders: StompHeaders) { + if (stompHeaders.get("message")?.single() == "Unauthenticated") { + authenticationStatus = AuthenticationStatus.UNAUTHENTICATED + } + } } /** @@ -143,4 +174,32 @@ class WebsocketTestHelper(val port: Int?, val jwtToken: String, val projectId: L assertCallback(receivedMessages) stop() } + + fun waitForForbidden() { + waitForAuthenticationStatus(MySessionHandler.AuthenticationStatus.FORBIDDEN) + } + + fun waitForUnauthenticated() { + waitForAuthenticationStatus(MySessionHandler.AuthenticationStatus.UNAUTHENTICATED) + + } + + fun waitForAuthenticationStatus(status: MySessionHandler.AuthenticationStatus) { + try { + waitFor(500) { + sessionHandler?.authenticationStatus == status + } + } catch (e: WaitNotSatisfiedException) { + logger.info("Authentication status was not $status, was: ${sessionHandler?.authenticationStatus}") + throw e + } + } + + data class Auth(val jwtToken: String? = null, val apiKey: String? = null) { + init { + if ((jwtToken == null && apiKey == null) || (jwtToken != null && apiKey != null)) { + throw IllegalArgumentException("Either jwtToken or apiKey must be provided") + } + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Pat.kt b/backend/data/src/main/kotlin/io/tolgee/model/Pat.kt index aff4fc1207..5eae4e082f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Pat.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Pat.kt @@ -1,5 +1,6 @@ package io.tolgee.model +import io.tolgee.security.PAT_PREFIX import jakarta.persistence.Entity import jakarta.persistence.Index import jakarta.persistence.ManyToOne @@ -41,4 +42,6 @@ class Pat( @ManyToOne @NotNull lateinit var userAccount: UserAccount + + val tokenWithPrefix get() = "$PAT_PREFIX$token" } diff --git a/webapp/src/websocket-client/WebsocketClient.ts b/webapp/src/websocket-client/WebsocketClient.ts index 305107dbaa..6d33a4f778 100644 --- a/webapp/src/websocket-client/WebsocketClient.ts +++ b/webapp/src/websocket-client/WebsocketClient.ts @@ -4,7 +4,7 @@ import { components } from 'tg.service/apiSchema.generated'; type BatchJobModelStatus = components['schemas']['BatchJobModel']['status']; -type TranslationsClientOptions = { +type WebsocketClientOptions = { serverUrl?: string; authentication: { jwtToken: string; @@ -27,7 +27,7 @@ type Subscription = { unsubscribe?: () => void; }; -export const WebsocketClient = (options: TranslationsClientOptions) => { +export const WebsocketClient = (options: WebsocketClientOptions) => { options.serverUrl = options.serverUrl || window.origin; let _client: CompatClient | undefined; @@ -96,12 +96,15 @@ export const WebsocketClient = (options: TranslationsClientOptions) => { options.onError?.(); }; - client.connect( - options.authentication.jwtToken ? { ...options.authentication } : null, - onConnected, - onError, - onDisconnect - ); + const headers: Record | null = options.authentication + .jwtToken + ? { + jwtToken: options.authentication.jwtToken, + Authorization: `Bearer ${options.authentication.jwtToken}`, + } + : null; + + client.connect(headers, onConnected, onError, onDisconnect); } const getClient = () => {