diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt index b781f2e120b..e146082245f 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/UserPreferencesController.kt @@ -58,7 +58,7 @@ class UserPreferencesController( @PathVariable organizationId: Long, ) { val organization = organizationService.get(organizationId) - organizationRoleService.checkUserCanView(organization.id) + organizationRoleService.checkUserCanViewOrPublic(organization.id) userPreferencesService.setPreferredOrganization(organization, authenticationFacade.authenticatedUserEntity) } diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt index 58c51991901..31bd6938f3b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt @@ -7,14 +7,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.activity.ActivityHolder import io.tolgee.api.isMfaEnabled -import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.component.PreferredOrganizationFacade import io.tolgee.constants.Message import io.tolgee.dtos.request.SuperTokenRequest import io.tolgee.dtos.request.UserUpdatePasswordRequestDto import io.tolgee.dtos.request.UserUpdateRequestDto import io.tolgee.exceptions.AuthenticationException import io.tolgee.hateoas.organization.PrivateOrganizationModel -import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler import io.tolgee.hateoas.organization.SimpleOrganizationModel import io.tolgee.hateoas.organization.SimpleOrganizationModelAssembler import io.tolgee.hateoas.sso.PublicSsoTenantModel @@ -67,14 +66,13 @@ class V2UserController( private val publicSsoTenantModelAssembler: PublicSsoTenantModelAssembler, private val imageUploadService: ImageUploadService, private val organizationService: OrganizationService, + private val preferredOrganizationFacade: PreferredOrganizationFacade, private val organizationRoleService: OrganizationRoleService, - private val privateOrganizationModelAssembler: PrivateOrganizationModelAssembler, private val tenantService: TenantService, private val simpleOrganizationModelAssembler: SimpleOrganizationModelAssembler, private val passwordEncoder: PasswordEncoder, private val jwtService: JwtService, private val mfaService: MfaService, - private val enabledFeaturesProvider: EnabledFeaturesProvider, private val emailVerificationService: EmailVerificationService, @Qualifier("requestActivityHolder") private val request: ActivityHolder, ) { @@ -246,15 +244,10 @@ class V2UserController( fun getManagedBy(): ResponseEntity { val userAccount = authenticationFacade.authenticatedUser val org = organizationRoleService.getManagedBy(userId = userAccount.id) ?: return ResponseEntity.noContent().build() - val view = - organizationService.findPrivateView(org.id, authenticationFacade.authenticatedUser.id) + val model = + preferredOrganizationFacade.getPrivateModel(org.id) ?: return ResponseEntity.noContent().build() - return ResponseEntity.ok( - privateOrganizationModelAssembler.toModel( - view, - enabledFeaturesProvider.get(view.organization.id), - ), - ) + return ResponseEntity.ok(model) } @PostMapping("") diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt index cbd1d8913f7..4e8b9d014be 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/organization/OrganizationLanguageController.kt @@ -7,6 +7,7 @@ import io.tolgee.hateoas.language.OrganizationLanguageModel import io.tolgee.hateoas.language.OrganizationLanguageModelAssembler import io.tolgee.security.authorization.UseDefaultPermissions import io.tolgee.service.language.LanguageService +import io.tolgee.service.security.SecurityService import org.springdoc.core.annotations.ParameterObject import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort @@ -29,6 +30,7 @@ class OrganizationLanguageController( private val languageService: LanguageService, private val organizationLanguageModelAssembler: OrganizationLanguageModelAssembler, private val pagedOrganizationLanguageAssembler: PagedResourcesAssembler, + private val securityService: SecurityService, ) { @Operation( summary = "Get all languages in use by projects owned by specified organization", @@ -46,7 +48,13 @@ class OrganizationLanguageController( @RequestParam("projectIds") projectIds: List?, @PathVariable organizationId: Long, ): PagedModel { - val languages = languageService.getPagedByOrganization(organizationId, projectIds, pageable, search) + val languages = + languageService.getPagedByOrganization( + organizationId, + securityService.getAccessibleProjectIdsInOrganization(organizationId, projectIds), + pageable, + search, + ) return pagedOrganizationLanguageAssembler.toModel(languages, organizationLanguageModelAssembler) } @@ -65,7 +73,13 @@ class OrganizationLanguageController( @RequestParam("projectIds") projectIds: List?, @PathVariable organizationId: Long, ): PagedModel { - val languages = languageService.getBasePagedByOrganization(organizationId, projectIds, pageable, search) + val languages = + languageService.getBasePagedByOrganization( + organizationId, + securityService.getAccessibleProjectIdsInOrganization(organizationId, projectIds), + pageable, + search, + ) return pagedOrganizationLanguageAssembler.toModel(languages, organizationLanguageModelAssembler) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt b/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt index 858ed9e411e..ec27e93a529 100644 --- a/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt +++ b/backend/api/src/main/kotlin/io/tolgee/component/PreferredOrganizationFacade.kt @@ -1,9 +1,11 @@ package io.tolgee.component import io.tolgee.component.enabledFeaturesProvider.EnabledFeaturesProvider +import io.tolgee.dtos.cacheable.isSupporterOrAdmin import io.tolgee.hateoas.organization.PrivateOrganizationModel import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.UserPreferencesService import org.springframework.stereotype.Component @@ -16,19 +18,30 @@ class PreferredOrganizationFacade( private val privateOrganizationModelAssembler: PrivateOrganizationModelAssembler, private val enabledFeaturesProvider: EnabledFeaturesProvider, private val organizationService: OrganizationService, + private val organizationRoleService: OrganizationRoleService, ) { fun getPreferred(): PrivateOrganizationModel? { - val preferences = userPreferencesService.findOrCreate(authenticationFacade.authenticatedUser.id) - val preferredOrganization = preferences.preferredOrganization - if (preferredOrganization != null) { - val view = - organizationService.findPrivateView(preferredOrganization.id, authenticationFacade.authenticatedUser.id) - ?: return null - return this.privateOrganizationModelAssembler.toModel( - view, - enabledFeaturesProvider.get(view.organization.id), - ) + val user = authenticationFacade.authenticatedUser + val preferences = userPreferencesService.findOrCreate(user.id) + var preferred = preferences.preferredOrganization + if (preferred == null || !organizationRoleService.canUserViewOrPublic(user, preferred.id)) { + preferred = userPreferencesService.refreshPreferredOrganization(user.id) ?: return null } - return null + + return getPrivateModel(preferred.id) + } + + fun getPrivateModel(organizationId: Long): PrivateOrganizationModel? { + val user = authenticationFacade.authenticatedUser + val view = organizationService.findPrivateView(organizationId, user.id) ?: return null + val isAtLeastMember = organizationRoleService.canUserViewAtLeastMember(user, organizationId) + val limitedView = + !user.isSupporterOrAdmin() && !organizationRoleService.canUserViewStrict(user.id, organizationId) + return privateOrganizationModelAssembler.toModel( + view, + enabledFeaturesProvider.get(view.organization.id), + isAtLeastMember, + limitedView, + ) } } diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt index f4526bb1644..af8503a74f5 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModel.kt @@ -17,5 +17,10 @@ open class PrivateOrganizationModel( val quickStart: QuickStartModel?, @get:Schema(example = "Current active subscription info") val activeCloudSubscription: PublicCloudSubscriptionModel?, + @get:Schema( + example = "false", + description = "Whether the user views the organization purely via public-project access", + ) + val limitedView: Boolean, ) : RepresentationModel(), IOrganizationModel by organizationModel diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModelAssembler.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModelAssembler.kt index a0e0393dcaa..b5b45739f29 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModelAssembler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/organization/PrivateOrganizationModelAssembler.kt @@ -4,6 +4,7 @@ import io.tolgee.constants.Feature import io.tolgee.dtos.queryResults.organization.PrivateOrganizationView import io.tolgee.hateoas.quickStart.QuickStartModelAssembler import io.tolgee.publicBilling.CloudSubscriptionModelProvider +import io.tolgee.publicBilling.PublicCloudSubscriptionModel import org.springframework.stereotype.Component @Component @@ -15,12 +16,27 @@ class PrivateOrganizationModelAssembler( fun toModel( view: PrivateOrganizationView, features: Array, + isAtLeastMember: Boolean, + limitedView: Boolean, ): PrivateOrganizationModel { + val organizationId = view.organization.id + val organizationModel = organizationModelAssembler.toModel(view.organization) return PrivateOrganizationModel( - organizationModel = organizationModelAssembler.toModel(view.organization), + organizationModel = organizationModel, enabledFeatures = features, quickStart = view.quickStart?.let { quickStartModelAssembler.toModel(it) }, - activeCloudSubscription = cloudSubscriptionModelProvider?.provide(view.organization.id), + activeCloudSubscription = activeCloudSubscription(isAtLeastMember, organizationId), + limitedView = limitedView, ) } + + private fun activeCloudSubscription( + isAtLeastMember: Boolean, + organizationId: Long, + ): PublicCloudSubscriptionModel? { + if (!isAtLeastMember) { + return null + } + return cloudSubscriptionModelProvider?.provide(organizationId) + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PreferredOrganizationCommunityTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PreferredOrganizationCommunityTest.kt new file mode 100644 index 00000000000..5d68deef58c --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PreferredOrganizationCommunityTest.kt @@ -0,0 +1,123 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsOk +import io.tolgee.model.UserAccount +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PreferredOrganizationCommunityTest : AuthorizedControllerTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `community user gets the reduced organization model`() { + setPreferred(testData.nonMember, testData.otherOrg.id) + userAccount = testData.nonMember + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(true) + node("basePermissions").isNotNull + node("activeCloudSubscription").isEqualTo(null) + } + } + + @Test + fun `member gets the full organization model`() { + setPreferred(testData.otherOrgMember, testData.otherOrg.id) + userAccount = testData.otherOrgMember + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo("MEMBER") + node("limitedView").isEqualTo(false) + } + } + + @Test + fun `direct project permission user gets the reduced model with no role but a non-limited view`() { + setPreferred(testData.directPermissionUser, testData.otherOrg.id) + userAccount = testData.directPermissionUser + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(false) + node("activeCloudSubscription").isEqualTo(null) + } + } + + @Test + fun `stale preference heals to a member organization`() { + setPreferred(testData.user, testData.otherOrg.id) + unpublishOtherOrgProject() + + userAccount = testData.user + val ownOrg = testData.userAccountBuilder.defaultOrganizationBuilder.self + performAuthGet("/v2/public/initial-data").andIsOk.andAssertThatJson { + node("preferredOrganization.id").isEqualTo(ownOrg.id) + node("preferredOrganization.currentUserRole").isEqualTo("OWNER") + } + assertStoredPreference(testData.user.id, ownOrg.id) + } + + @Test + fun `stale preference with no other viewable organization heals to a created one`() { + setPreferred(testData.nonMember, testData.otherOrg.id) + unpublishOtherOrgProject() + executeInNewTransaction { + organizationService.delete(organizationService.get(testData.nonMemberPersonalOrg.id)) + } + + userAccount = testData.nonMember + performAuthGet("/v2/public/initial-data").andIsOk.andAssertThatJson { + node("preferredOrganization.name").isEqualTo("Non Member") + node("preferredOrganization.currentUserRole").isEqualTo("OWNER") + } + executeInNewTransaction { + val preferred = userPreferencesService.find(testData.nonMember.id)!!.preferredOrganization!! + assertThat(preferred.id).isNotEqualTo(testData.otherOrg.id) + assertThat(preferred.name).isEqualTo("Non Member") + } + } + + private fun setPreferred( + user: UserAccount, + organizationId: Long, + ) { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(organizationId), + userAccountService.get(user.id), + ) + } + } + + private fun unpublishOtherOrgProject() { + executeInNewTransaction { + projectService.get(testData.otherOrgPublicProject.id).public = false + } + } + + private fun assertStoredPreference( + userId: Long, + organizationId: Long, + ) { + executeInNewTransaction { + assertThat(userPreferencesService.find(userId)!!.preferredOrganization!!.id).isEqualTo(organizationId) + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt index 8d0163115ba..13918b21b75 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicProjectsControllerTest.kt @@ -6,6 +6,7 @@ import io.tolgee.fixtures.andIsOk import io.tolgee.fixtures.node import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assert +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -20,26 +21,11 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { fun setup() { testData = PublicProjectsControllerTestData() testDataService.saveTestData(testData.root) - // Drive the degenerate-exclusion projects into states the JPA layer self-heals/forbids on entity - // save, via native SQL (the list query is a projection, so it never reloads them as entities). - executeInNewTransaction { - entityManager - .createNativeQuery("update project set base_language_id = null where id = :id") - .setParameter("id", testData.noBaseLanguageProject.id) - .executeUpdate() - entityManager - .createNativeQuery("update language set deleted_at = now() where project_id = :id") - .setParameter("id", testData.softDeletedBaseProject.id) - .executeUpdate() - entityManager - .createNativeQuery("update project set organization_owner_id = null where id = :id") - .setParameter("id", testData.orgLessProject.id) - .executeUpdate() - entityManager - .createNativeQuery("update project set deleted_at = now() where id = :id") - .setParameter("id", testData.deletedPublicProject.id) - .executeUpdate() - } + } + + @AfterEach + fun cleanup() { + testDataService.cleanTestData(testData.root) } @Test @@ -187,6 +173,25 @@ class PublicProjectsControllerTest : AuthorizedControllerTest() { } } + @Test + fun `excludes a public project of a soft-deleted organization`() { + performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson { + node("_embedded.projects").isArray.hasSize(2) + } + } + + @Test + fun `hasPublicProjects matches the public listing visibility exactly`() { + val defaultOrg = testData.userAccountBuilder.defaultOrganizationBuilder.self + projectService.hasPublicProjects(defaultOrg.id).assert.isTrue() + projectService.hasPublicProjects(testData.otherOrg.id).assert.isTrue() + projectService.hasPublicProjects(testData.noPublicOrg.id).assert.isFalse() + projectService.hasPublicProjects(testData.noBaseLangOnlyOrg.id).assert.isFalse() + projectService.hasPublicProjects(testData.softDeletedBaseLangOnlyOrg.id).assert.isFalse() + projectService.hasPublicProjects(testData.deletedProjectOnlyOrg.id).assert.isFalse() + projectService.hasPublicProjects(testData.softDeletedOrg.id).assert.isFalse() + } + private fun baseLanguageId(projectId: Long): Long? = executeInNewTransaction { ( diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesSetPreferredOrganizationTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesSetPreferredOrganizationTest.kt new file mode 100644 index 00000000000..0c461235547 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/UserPreferencesSetPreferredOrganizationTest.kt @@ -0,0 +1,82 @@ +package io.tolgee.api.v2.controllers + +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UserPreferencesSetPreferredOrganizationTest : AuthorizedControllerTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `non-member sets preferred organization owning a public project`() { + userAccount = testData.nonMember + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.otherOrg.id}", null).andIsOk + assertPreferredOrganization(testData.nonMember.id, testData.otherOrg.id) + } + + @Test + fun `non-member cannot set preferred organization without public projects`() { + userAccount = testData.nonMember + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.noPublicOrg.id}", null) + .andIsForbidden + } + + @Test + fun `non-member cannot set preferred organization whose only public project is not publicly visible`() { + userAccount = testData.nonMember + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.noBaseLangOnlyOrg.id}", null) + .andIsForbidden + } + + @Test + fun `member still sets preferred organization`() { + userAccount = testData.otherOrgMember + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.otherOrg.id}", null).andIsOk + assertPreferredOrganization(testData.otherOrgMember.id, testData.otherOrg.id) + } + + @Test + fun `direct project permission user still sets preferred organization`() { + userAccount = testData.directPermissionUser + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.otherOrg.id}", null).andIsOk + assertPreferredOrganization(testData.directPermissionUser.id, testData.otherOrg.id) + } + + @Test + fun `server admin sets preferred organization without public projects`() { + userAccount = testData.serverAdmin + performAuthPut("/v2/user-preferences/set-preferred-organization/${testData.noPublicOrg.id}", null).andIsOk + assertPreferredOrganization(testData.serverAdmin.id, testData.noPublicOrg.id) + } + + @Test + fun `anonymous cannot set preferred organization`() { + performPut("/v2/user-preferences/set-preferred-organization/${testData.otherOrg.id}", null) + .andIsForbidden + } + + private fun assertPreferredOrganization( + userId: Long, + organizationId: Long, + ) { + executeInNewTransaction { + assertThat(userPreferencesService.find(userId)!!.preferredOrganization!!.id).isEqualTo(organizationId) + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationCommunityAccessTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationCommunityAccessTest.kt new file mode 100644 index 00000000000..c1b04db8dac --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationCommunityAccessTest.kt @@ -0,0 +1,83 @@ +package io.tolgee.api.v2.controllers.organizationController + +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.dtos.request.organization.OrganizationDto +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.testing.AuthorizedControllerTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class OrganizationCommunityAccessTest : AuthorizedControllerTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `community user gets basic info of an organization with a public project by id`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.otherOrg.id}").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo(null) + node("basePermissions").isNotNull + } + } + + @Test + fun `community user gets basic info of an organization with a public project by slug`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.otherOrg.slug}").andIsOk.andAssertThatJson { + node("name").isEqualTo("Vibrant translators") + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `community user cannot see an organization without public projects`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.noPublicOrg.id}").andIsNotFound + performAuthGet("/v2/organizations/${testData.noPublicOrg.slug}").andIsNotFound + } + + @Test + fun `community user cannot see an organization whose only public project is not publicly visible`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.noBaseLangOnlyOrg.id}").andIsNotFound + } + + @Test + fun `anonymous cannot see any organization`() { + performGet("/v2/organizations/${testData.otherOrg.id}").andIsForbidden + performGet("/v2/organizations/${testData.otherOrg.slug}").andIsForbidden + performGet("/v2/organizations/${testData.noPublicOrg.id}").andIsForbidden + } + + @Test + fun `community access does not open write methods on the same path`() { + userAccount = testData.nonMember + performAuthPut( + "/v2/organizations/${testData.otherOrg.id}", + OrganizationDto(name = "Hijacked", slug = testData.otherOrg.slug), + ).andIsForbidden + } + + @Test + fun `community access does not open member endpoints`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.otherOrg.id}/users").andIsForbidden + performAuthGet("/v2/organizations/${testData.otherOrg.id}/usage").andIsForbidden + performAuthGet("/v2/organizations/${testData.otherOrg.id}/invitations").andIsForbidden + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt new file mode 100644 index 00000000000..c8cf66765fe --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationFloorAccessTest.kt @@ -0,0 +1,383 @@ +package io.tolgee.api.v2.controllers.organizationController + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.dtos.request.userAccount.UserAccountPermissionsFilters +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert +import net.javacrumbs.jsonunit.core.internal.Node.JsonMap +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.data.domain.PageRequest + +class OrganizationFloorAccessTest : AuthorizedControllerTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `a public-project floor viewer does not inherit the organization base permission`() { + userAccount = testData.storedGuest + performAuthGet("/v2/projects/${testData.otherOrgPrivateProject.id}").andIsNotFound + performAuthGet("/v2/projects/${testData.otherOrgPublicProject.id}").andIsOk.andAssertThatJson { + node("computedPermission.origin").isEqualTo("COMMUNITY") + } + } + + @Test + fun `a floor viewer sees only public projects in the organization listing`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations/${testData.otherOrg.slug}/projects-with-stats") + .andIsOk + .andAssertThatJson { + node("_embedded.projects") { + isArray.hasSize(1) + node("[0].id").isEqualTo(testData.otherOrgPublicProject.id) + } + } + } + + @Test + fun `member sees private and public projects in the organization listing`() { + userAccount = testData.otherOrgMember + performAuthGet("/v2/organizations/${testData.otherOrg.slug}/projects-with-stats") + .andIsOk + .andAssertThatJson { + node("_embedded.projects").isArray.hasSize(2) + } + } + + @Test + fun `a floor viewer personal project list contains nothing from the organization`() { + userAccount = testData.storedGuest + performAuthGet("/v2/projects").andIsOk.andAssertThatJson { + node("page.totalElements").isEqualTo(0) + } + } + + @Test + fun `a floor viewer is denied member endpoints`() { + userAccount = testData.storedGuest + performAuthGet("/v2/organizations/${testData.otherOrg.id}/users").andIsForbidden + performAuthGet("/v2/organizations/${testData.otherOrg.id}/usage").andIsForbidden + performAuthGet("/v2/organizations/${testData.otherOrg.id}/translation-memories").andIsForbidden + } + + @Test + fun `llm providers are floor-visible`() { + userAccount = testData.storedGuest + performAuthGet("/v2/organizations/${testData.otherOrg.id}/llm-providers/all-available").andIsOk + } + + @Test + fun `a floor viewer sees only public-project languages, not private-project ones`() { + userAccount = testData.nonMember + val languages = orgLanguageTags("/languages") + languages.assert.contains("en") + languages.assert.doesNotContain("de") + val baseLanguages = orgLanguageTags("/base-languages") + baseLanguages.assert.contains("en") + baseLanguages.assert.doesNotContain("de") + } + + @Test + fun `a member sees languages of all organization projects, private included`() { + userAccount = testData.otherOrgMember + orgLanguageTags("/languages").assert.contains("en", "de") + orgLanguageTags("/base-languages").assert.contains("en", "de") + } + + @Test + fun `a floor viewer cannot probe a private project's languages via the projectIds filter`() { + val privateId = testData.otherOrgPrivateProject.id + userAccount = testData.nonMember + orgLanguageTags("/languages", "&projectIds=$privateId").assert.isEmpty() + orgLanguageTags("/base-languages", "&projectIds=$privateId").assert.isEmpty() + + userAccount = testData.otherOrgMember + orgLanguageTags("/languages", "&projectIds=$privateId").assert.contains("de") + orgLanguageTags("/base-languages", "&projectIds=$privateId").assert.contains("de") + } + + private fun orgLanguageTags( + path: String, + query: String = "", + ): List { + val response = + performAuthGet("/v2/organizations/${testData.otherOrg.id}$path?size=100$query").andIsOk.andReturn() + return jacksonObjectMapper() + .readTree(response.response.contentAsString) + .path("_embedded") + .path("languages") + .map { it.path("tag").asText() } + } + + @Test + fun `a floor viewer reports no role on the org endpoint`() { + userAccount = testData.storedGuest + performAuthGet("/v2/organizations/${testData.otherOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `the organizations listing stays membership-scoped - a floor viewer is not in it`() { + userAccount = testData.nonMember + performAuthGet("/v2/organizations?search=Vibrant").andIsOk.andAssertThatJson { + node("page.totalElements").isEqualTo(0) + } + + userAccount = testData.noneOnlyUser + performAuthGet("/v2/organizations?search=Vibrant").andIsOk.andAssertThatJson { + node("page.totalElements").isEqualTo(0) + } + + userAccount = testData.otherOrgMember + performAuthGet("/v2/organizations?search=Vibrant").andIsOk.andAssertThatJson { + node("_embedded.organizations").isArray.hasSize(1) + } + } + + @Test + fun `a member of a private-only org has standing and reports the MEMBER role`() { + userAccount = testData.privateOrgMember + performAuthGet("/v2/organizations/${testData.noPublicOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo("MEMBER") + } + } + + @Test + fun `server admin sees no role on the org endpoint`() { + userAccount = testData.serverAdmin + performAuthGet("/v2/organizations/${testData.otherOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `a granular scopes-only permission grants standing but no role`() { + executeInNewTransaction { + organizationRoleService + .canUserViewStrictOrPublic(testData.granularPermissionUser.id, testData.otherOrg.id) + .assert + .isTrue() + organizationRoleService + .findType(testData.granularPermissionUser.id, testData.otherOrg.id) + .assert + .isNull() + } + } + + @Test + fun `direct permission holder sees no role on the org endpoint`() { + userAccount = testData.directPermissionUser + performAuthGet("/v2/organizations/${testData.otherOrg.id}").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + } + } + + @Test + fun `a direct-permission holder can leave the project`() { + userAccount = testData.guestWithPermission + performAuthPut("/v2/projects/${testData.otherOrgPrivateProject.id}/leave", null).andIsOk + performAuthGet("/v2/projects/${testData.otherOrgPrivateProject.id}").andIsNotFound + } + + @Test + fun `a NONE-only permission is treated as a plain floor viewer, not standing`() { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(testData.otherOrg.id), + userAccountService.get(testData.noneOnlyUser.id), + ) + } + userAccount = testData.noneOnlyUser + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("id").isEqualTo(testData.otherOrg.id) + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(true) + node("activeCloudSubscription").isEqualTo(null) + } + } + + @Test + fun `a NONE-only permission does not expose the private project's languages`() { + val publicId = testData.otherOrgPublicProject.id + val privateId = testData.otherOrgPrivateProject.id + userAccount = testData.noneOnlyUser + orgLanguageTags("/languages").let { + it.assert.contains("en") + it.assert.doesNotContain("de") + } + orgLanguageTags("/languages", "&projectIds=$publicId&projectIds=$privateId").let { + it.assert.contains("en") + it.assert.doesNotContain("de") + } + } + + @Test + fun `a floor viewer sees an accessible public project's languages via the projectIds filter`() { + val publicId = testData.otherOrgPublicProject.id + userAccount = testData.nonMember + orgLanguageTags("/languages", "&projectIds=$publicId").assert.contains("en") + orgLanguageTags("/base-languages", "&projectIds=$publicId").assert.contains("en") + } + + @Test + fun `a floor viewer leaving the org 404s from the service, not access denial`() { + userAccount = testData.nonMember + performAuthPut("/v2/organizations/${testData.otherOrg.id}/leave", null) + .andIsNotFound + .andAssertThatJson { + node("code").isEqualTo("user_is_not_member_of_organization") + } + } + + @Test + fun `a NONE-only permission on a private-only org grants no org access`() { + userAccount = testData.revokedOnlyUser + performAuthGet("/v2/organizations/${testData.noPublicOrg.id}").andIsNotFound + executeInNewTransaction { + organizationRoleService + .canUserViewStrictOrPublic(testData.revokedOnlyUser.id, testData.noPublicOrg.id) + .assert + .isFalse() + } + } + + @Test + fun `a stale preference to a private-only org heals away for a NONE-only user`() { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(testData.noPublicOrg.id), + userAccountService.get(testData.revokedOnlyUser.id), + ) + } + userAccount = testData.revokedOnlyUser + val response = performAuthGet("/v2/preferred-organization").andIsOk.andReturn() + val id = + jacksonObjectMapper() + .readTree(response.response.contentAsString) + .path("id") + .asLong() + id.assert.isNotEqualTo(testData.noPublicOrg.id) + } + + @Test + fun `a role-less server admin gets the full model with limitedView false`() { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(testData.otherOrg.id), + userAccountService.get(testData.serverAdmin.id), + ) + } + userAccount = testData.serverAdmin + performAuthGet("/v2/preferred-organization").andIsOk.andAssertThatJson { + node("currentUserRole").isEqualTo(null) + node("limitedView").isEqualTo(false) + } + } + + @Test + fun `a public-project floor viewer does not leak into org member management`() { + userAccount = testData.otherOrgOwner + performAuthGet("/v2/organizations/${testData.otherOrg.id}/users?size=50").andIsOk.andAssertThatJson { + node("_embedded.usersInOrganization") { + isArray.isNotEmpty() + isArray.allSatisfy { + (it as JsonMap)["username"].assert.isNotEqualTo("stored_guest") + } + } + } + } + + @Test + fun `project member listing excludes floor viewers`() { + userAccount = testData.otherOrgOwner + performAuthGet("/v2/projects/${testData.otherOrgPublicProject.id}/users").andIsOk.andAssertThatJson { + node("_embedded.users").isArray.hasSize(3) + } + } + + @Test + fun `project stats member count excludes floor viewers`() { + userAccount = testData.otherOrgOwner + performAuthGet("/v2/projects/${testData.otherOrgPublicProject.id}/stats").andIsOk.andAssertThatJson { + node("membersCount").isEqualTo(3) + } + } + + @Test + fun `permitted-user search excludes floor viewers`() { + executeInNewTransaction { + val users = + userAccountService.findWithMinimalPermissions( + UserAccountPermissionsFilters(), + testData.otherOrgPublicProject.id, + search = null, + pageable = PageRequest.of(0, 20), + ) + users.content + .map { it.username } + .assert + .containsExactlyInAnyOrder("other_org_member", "other_org_owner", "direct_perm_user") + } + } + + @Test + fun `a role in a soft-deleted organization grants no standing`() { + executeInNewTransaction { + organizationRoleService + .canUserViewStrictOrPublic(testData.softDeletedOrgMember.id, testData.softDeletedOrg.id) + .assert + .isFalse() + } + } + + @Test + fun `direct permission in a soft-deleted organization grants no standing`() { + executeInNewTransaction { + organizationService.delete(organizationService.get(testData.otherOrg.id)) + } + executeInNewTransaction { + organizationRoleService + .canUserViewStrictOrPublic(testData.directPermissionUser.id, testData.otherOrg.id) + .assert + .isFalse() + } + } + + @Test + fun `a permission-held project in a soft-deleted org is not below-member accessible`() { + executeInNewTransaction { + projectService + .getBelowMemberAccessibleProjectIds(testData.otherOrg.id, testData.guestWithPermission.id) + .assert + .contains(testData.otherOrgPrivateProject.id) + } + executeInNewTransaction { + organizationService.delete(organizationService.get(testData.otherOrg.id)) + } + executeInNewTransaction { + projectService + .getBelowMemberAccessibleProjectIds(testData.otherOrg.id, testData.guestWithPermission.id) + .assert + .isEmpty() + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/service/RefreshPreferredOrganizationTest.kt b/backend/app/src/test/kotlin/io/tolgee/service/RefreshPreferredOrganizationTest.kt new file mode 100644 index 00000000000..c613a1cd74a --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/service/RefreshPreferredOrganizationTest.kt @@ -0,0 +1,86 @@ +package io.tolgee.service + +import io.tolgee.AbstractSpringTest +import io.tolgee.development.testDataBuilder.data.PublicProjectsControllerTestData +import io.tolgee.model.UserAccount +import io.tolgee.testing.assertions.Assertions.assertThat +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 + +@SpringBootTest +class RefreshPreferredOrganizationTest : AbstractSpringTest() { + lateinit var testData: PublicProjectsControllerTestData + + @BeforeEach + fun setup() { + testData = PublicProjectsControllerTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun clean() { + testDataService.cleanTestData(testData.root) + } + + @Test + fun `keeps a community-viewable preference after leaving the organization`() { + setPreferred(testData.otherOrgMember, testData.otherOrg.id) + executeInNewTransaction { + organizationRoleService.removeUser(testData.otherOrgMember.id, testData.otherOrg.id) + } + assertThat(preferredOrganizationId(testData.otherOrgMember)).isEqualTo(testData.otherOrg.id) + } + + @Test + fun `evicts the preference after leaving an organization without public projects`() { + setPreferred(testData.noPublicOrgMember, testData.noPublicOrg.id) + executeInNewTransaction { + organizationRoleService.removeUser(testData.noPublicOrgMember.id, testData.noPublicOrg.id) + } + assertThat(preferredOrganizationId(testData.noPublicOrgMember)).isNotEqualTo(testData.noPublicOrg.id) + } + + @Test + fun `keeps a community-viewable preference after direct permission removal`() { + setPreferred(testData.directPermissionUser, testData.otherOrg.id) + executeInNewTransaction { + val permission = + permissionService + .getProjectPermissionData( + testData.otherOrgPublicProject.id, + testData.directPermissionUser.id, + ).directPermissions + permissionService.delete(permission!!.id) + } + assertThat(preferredOrganizationId(testData.directPermissionUser)).isEqualTo(testData.otherOrg.id) + } + + @Test + fun `soft-deleting the organization evicts the preference`() { + setPreferred(testData.nonMember, testData.otherOrg.id) + executeInNewTransaction { + organizationService.delete(organizationService.get(testData.otherOrg.id)) + } + assertThat(preferredOrganizationId(testData.nonMember)).isNotEqualTo(testData.otherOrg.id) + } + + private fun setPreferred( + user: UserAccount, + organizationId: Long, + ) { + executeInNewTransaction { + userPreferencesService.setPreferredOrganization( + organizationService.get(organizationId), + userAccountService.get(user.id), + ) + } + } + + private fun preferredOrganizationId(user: UserAccount): Long? { + return executeInNewTransaction { + userPreferencesService.find(user.id)?.preferredOrganization?.id + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/unit/PrivateOrganizationModelAssemblerTest.kt b/backend/app/src/test/kotlin/io/tolgee/unit/PrivateOrganizationModelAssemblerTest.kt new file mode 100644 index 00000000000..d1fd1eedbf6 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/unit/PrivateOrganizationModelAssemblerTest.kt @@ -0,0 +1,114 @@ +package io.tolgee.unit + +import io.tolgee.constants.Feature +import io.tolgee.dtos.queryResults.organization.PrivateOrganizationView +import io.tolgee.hateoas.organization.OrganizationModel +import io.tolgee.hateoas.organization.OrganizationModelAssembler +import io.tolgee.hateoas.organization.PrivateOrganizationModelAssembler +import io.tolgee.hateoas.permission.PermissionModel +import io.tolgee.hateoas.quickStart.QuickStartModelAssembler +import io.tolgee.publicBilling.CloudSubscriptionModelProvider +import io.tolgee.publicBilling.PublicCloudSubscriptionModel +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class PrivateOrganizationModelAssemblerTest { + private val organizationModelAssembler = Mockito.mock(OrganizationModelAssembler::class.java) + private val quickStartModelAssembler = Mockito.mock(QuickStartModelAssembler::class.java) + private val cloudSubscriptionModelProvider = Mockito.mock(CloudSubscriptionModelProvider::class.java) + + private val underTest = + PrivateOrganizationModelAssembler( + organizationModelAssembler, + quickStartModelAssembler, + cloudSubscriptionModelProvider, + ) + + private val view = Mockito.mock(PrivateOrganizationView::class.java) + + private val organizationModel = + OrganizationModel( + id = 42L, + name = "The org", + slug = "the-org", + description = null, + basePermissions = Mockito.mock(PermissionModel::class.java), + currentUserRole = null, + avatar = null, + ) + + private fun setup() { + val organizationView = + Mockito.mock(io.tolgee.dtos.queryResults.organization.OrganizationView::class.java) + whenever(organizationView.id).thenReturn(42L) + whenever(view.organization).thenReturn(organizationView) + whenever(view.quickStart).thenReturn(null) + whenever(organizationModelAssembler.toModel(any())).thenReturn(organizationModel) + } + + @Test + fun `a below-member reader gets no subscription and maps the passed limitedView`() { + setup() + + val model = + underTest.toModel( + view, + arrayOf(Feature.GLOSSARY, Feature.PREMIUM_SUPPORT), + isAtLeastMember = false, + limitedView = true, + ) + + model.enabledFeatures + .toSet() + .assert + .isEqualTo(setOf(Feature.GLOSSARY, Feature.PREMIUM_SUPPORT)) + model.currentUserRole.assert.isNull() + model.limitedView.assert.isEqualTo(true) + model.activeCloudSubscription.assert.isNull() + verify(cloudSubscriptionModelProvider, never()).provide(any()) + } + + @Test + fun `a below-member with standing gets no subscription but a non-limited view`() { + setup() + + val model = + underTest.toModel( + view, + arrayOf(Feature.GLOSSARY), + isAtLeastMember = false, + limitedView = false, + ) + + model.limitedView.assert.isEqualTo(false) + model.activeCloudSubscription.assert.isNull() + verify(cloudSubscriptionModelProvider, never()).provide(any()) + } + + @Test + fun `a member gets features, the cloud subscription and a non-limited view`() { + setup() + val subscription = Mockito.mock(PublicCloudSubscriptionModel::class.java) + whenever(cloudSubscriptionModelProvider.provide(42L)).thenReturn(subscription) + + val model = + underTest.toModel( + view, + arrayOf(Feature.PREMIUM_SUPPORT, Feature.GLOSSARY), + isAtLeastMember = true, + limitedView = false, + ) + + model.enabledFeatures + .toSet() + .assert + .isEqualTo(setOf(Feature.PREMIUM_SUPPORT, Feature.GLOSSARY)) + model.limitedView.assert.isEqualTo(false) + model.activeCloudSubscription.assert.isEqualTo(subscription) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index 458618db4f9..f0725561b70 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -141,6 +141,12 @@ class TestDataService( } updateLanguageStats(builder) + + if (builder.afterSaveRawStates.isNotEmpty()) { + executeInNewTransaction(transactionManager) { + builder.afterSaveRawStates.forEach { it(entityManager) } + } + } } finally { activityHolder.enableAutoCompletion = true bypassableActivityListeners.forEach { it.bypass = false } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/LanguageBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/LanguageBuilder.kt index 1011af974af..c0819dbf2ab 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/LanguageBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/LanguageBuilder.kt @@ -4,6 +4,7 @@ import io.tolgee.development.testDataBuilder.EntityDataBuilder import io.tolgee.development.testDataBuilder.FT import io.tolgee.model.Language import io.tolgee.model.qa.LanguageQaConfig +import java.util.Date class LanguageBuilder( val projectBuilder: ProjectBuilder, @@ -24,4 +25,10 @@ class LanguageBuilder( data.qaConfig = builder return builder } + + fun setDeletedAt(deletedAt: Date = Date()) { + projectBuilder.testDataBuilder.rawUpdateAfterSave("update language set deleted_at = :deletedAt where id = :id") { + mapOf("deletedAt" to deletedAt, "id" to self.id) + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt index 3693de9686e..2b472c0be08 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/OrganizationBuilder.kt @@ -14,6 +14,7 @@ import io.tolgee.model.glossary.Glossary import io.tolgee.model.slackIntegration.OrganizationSlackWorkspace import io.tolgee.model.translationMemory.TranslationMemory import org.springframework.core.io.ClassPathResource +import java.util.Date class OrganizationBuilder( val testDataBuilder: TestDataBuilder, @@ -87,4 +88,10 @@ class OrganizationBuilder( fun addGlossary(ft: FT) = addOperation(data.glossaries, ft) fun addTranslationMemory(ft: FT) = addOperation(data.translationMemories, ft) + + fun setDeletedAt(deletedAt: Date = Date()) { + testDataBuilder.rawUpdateAfterSave("update organization set deleted_at = :deletedAt where id = :id") { + mapOf("deletedAt" to deletedAt, "id" to self.id) + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt index 8a65e02ea2e..651e4378203 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/ProjectBuilder.kt @@ -38,6 +38,7 @@ import io.tolgee.model.translationMemory.TranslationMemory import io.tolgee.model.translationMemory.TranslationMemoryType import io.tolgee.model.webhook.WebhookConfig import org.springframework.core.io.ClassPathResource +import java.util.Date class ProjectBuilder( organizationOwner: Organization? = null, @@ -315,4 +316,22 @@ class ProjectBuilder( } if (!alreadyDeclared) addProjectTm() } + + fun setDeletedAt(deletedAt: Date = Date()) { + testDataBuilder.rawUpdateAfterSave("update project set deleted_at = :deletedAt where id = :id") { + mapOf("deletedAt" to deletedAt, "id" to self.id) + } + } + + fun clearBaseLanguage() { + testDataBuilder.rawUpdateAfterSave("update project set base_language_id = null where id = :id") { + mapOf("id" to self.id) + } + } + + fun clearOrganizationOwner() { + testDataBuilder.rawUpdateAfterSave("update project set organization_owner_id = null where id = :id") { + mapOf("id" to self.id) + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt index f54bba13058..beebf955e1c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/builders/TestDataBuilder.kt @@ -4,6 +4,7 @@ import io.tolgee.model.Organization import io.tolgee.model.Project import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType +import jakarta.persistence.EntityManager class TestDataBuilder( fn: (TestDataBuilder.() -> Unit) = {}, @@ -28,6 +29,24 @@ class TestDataBuilder( val data = DATA() + /** Applied raw after save: states the JPA layer self-heals or forbids on entity save (soft deletes, missing base language). */ + val afterSaveRawStates = mutableListOf<(EntityManager) -> Unit>() + + fun rawStateAfterSave(fn: (EntityManager) -> Unit) { + afterSaveRawStates.add(fn) + } + + fun rawUpdateAfterSave( + sql: String, + params: () -> Map, + ) { + rawStateAfterSave { em -> + val query = em.createNativeQuery(sql) + params().forEach { (name, value) -> query.setParameter(name, value) } + query.executeUpdate() + } + } + fun addUserAccountWithoutOrganization(ft: UserAccount.() -> Unit): UserAccountBuilder { val builder = UserAccountBuilder(this) data.userAccounts.add(builder) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryGuestAccessTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryGuestAccessTestData.kt new file mode 100644 index 00000000000..4f8bec1a1ec --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/GlossaryGuestAccessTestData.kt @@ -0,0 +1,199 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.development.testDataBuilder.builders.LanguageBuilder +import io.tolgee.development.testDataBuilder.builders.ProjectBuilder +import io.tolgee.model.Organization +import io.tolgee.model.Project +import io.tolgee.model.UserAccount +import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.glossary.Glossary +import io.tolgee.model.glossary.GlossaryTerm + +class GlossaryGuestAccessTestData : BaseTestData() { + lateinit var organization: Organization + lateinit var publicProject: Project + lateinit var privateProject: Project + lateinit var publicGlossary: Glossary + lateinit var publicGlossaryTerm: GlossaryTerm + lateinit var privateGlossary: Glossary + lateinit var privateGlossaryTerm: GlossaryTerm + lateinit var mixedGlossary: Glossary + lateinit var degenerateProject: Project + lateinit var degenerateGlossary: Glossary + lateinit var softDeletedProject: Project + lateinit var softDeletedProjectGlossary: Glossary + lateinit var softDeletedBaseLangProject: Project + lateinit var softDeletedBaseLangGlossary: Glossary + lateinit var storedGuest: UserAccount + lateinit var virtualGuest: UserAccount + lateinit var directPermissionUser: UserAccount + lateinit var noneOnlyUser: UserAccount + + init { + root.apply { + storedGuest = + addUserAccount { + username = "glossary_stored_guest" + name = "Glossary Stored Guest" + }.self + + virtualGuest = + addUserAccount { + username = "glossary_virtual_guest" + name = "Glossary Virtual Guest" + }.self + + directPermissionUser = + addUserAccount { + username = "glossary_direct_perm_user" + name = "Glossary Direct Perm User" + }.self + + noneOnlyUser = + addUserAccount { + username = "glossary_none_only_user" + name = "Glossary None Only User" + }.self + + userAccountBuilder.defaultOrganizationBuilder.build { + organization = self + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Guest visible public project" + public = true + }.build { + publicProject = self + addBaseLanguage() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "A members only project" + }.build { + privateProject = self + addBaseLanguage() + addLanguage { + name = "German" + tag = "de" + originalName = "Deutsch" + } + addPermission { + user = this@GlossaryGuestAccessTestData.directPermissionUser + type = ProjectPermissionType.TRANSLATE + } + addPermission { + user = this@GlossaryGuestAccessTestData.storedGuest + type = ProjectPermissionType.NONE + } + addPermission { + user = this@GlossaryGuestAccessTestData.noneOnlyUser + type = ProjectPermissionType.NONE + } + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Degenerate public project" + public = true + }.build { + degenerateProject = self + addBaseLanguage() + clearBaseLanguage() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Soft-deleted public project" + public = true + }.build { + softDeletedProject = self + addBaseLanguage() + setDeletedAt() + } + + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { + name = "Soft-deleted base language public project" + public = true + }.build { + softDeletedBaseLangProject = self + addBaseLanguage().setDeletedAt() + } + + userAccountBuilder.defaultOrganizationBuilder.build { + publicGlossary = + addGlossary { + name = "Public project glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.publicProject) + publicGlossaryTerm = + addTerm { + description = "Public term" + }.build { + addTranslation { + languageTag = "en" + text = "public term" + } + }.self + }.self + + privateGlossary = + addGlossary { + name = "Private project glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.privateProject) + privateGlossaryTerm = + addTerm { + description = "Members-only term" + }.build { + addTranslation { + languageTag = "en" + text = "members-only term" + } + }.self + }.self + + mixedGlossary = + addGlossary { + name = "Mixed assignment glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.publicProject) + assignProject(this@GlossaryGuestAccessTestData.privateProject) + }.self + + degenerateGlossary = + addGlossary { + name = "Degenerate project glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.degenerateProject) + }.self + + softDeletedProjectGlossary = + addGlossary { + name = "Soft-deleted project glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.softDeletedProject) + }.self + + softDeletedBaseLangGlossary = + addGlossary { + name = "Soft-deleted base language glossary" + baseLanguageTag = "en" + }.build { + assignProject(this@GlossaryGuestAccessTestData.softDeletedBaseLangProject) + }.self + } + } + } + + private fun ProjectBuilder.addBaseLanguage(): LanguageBuilder { + return addLanguage { + name = "English" + tag = "en" + originalName = "English" + this@addBaseLanguage.self.baseLanguage = this + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt index 2ff78893b9f..23a9390d130 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsControllerTestData.kt @@ -1,10 +1,13 @@ package io.tolgee.development.testDataBuilder.data +import io.tolgee.development.testDataBuilder.builders.LanguageBuilder import io.tolgee.development.testDataBuilder.builders.ProjectBuilder import io.tolgee.model.Organization import io.tolgee.model.Project import io.tolgee.model.UserAccount +import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.model.enums.ProjectPermissionType +import io.tolgee.model.enums.Scope class PublicProjectsControllerTestData : BaseTestData() { val privateProject: Project get() = project @@ -21,13 +24,42 @@ class PublicProjectsControllerTestData : BaseTestData() { lateinit var orgLessProject: Project lateinit var deletedPublicProject: Project + lateinit var nonMemberPersonalOrg: Organization + lateinit var otherOrgMember: UserAccount + lateinit var otherOrgOwner: UserAccount + lateinit var storedGuest: UserAccount + lateinit var otherOrgPrivateProject: Project + lateinit var serverAdmin: UserAccount + + lateinit var guestWithPermission: UserAccount + lateinit var granularPermissionUser: UserAccount + lateinit var noneOnlyUser: UserAccount + lateinit var revokedOnlyUser: UserAccount + + lateinit var noPublicOrg: Organization + lateinit var noPublicOrgMember: UserAccount + lateinit var noPublicOrgOwner: UserAccount + lateinit var privateOrgMember: UserAccount + + lateinit var noBaseLangOnlyOrg: Organization + lateinit var noBaseLangOnlyOrgProject: Project + lateinit var softDeletedBaseLangOnlyOrg: Organization + lateinit var softDeletedBaseLangOnlyOrgProject: Project + lateinit var deletedProjectOnlyOrg: Organization + lateinit var deletedProjectOnlyOrgProject: Project + lateinit var softDeletedOrg: Organization + lateinit var softDeletedOrgPublicProject: Project + lateinit var softDeletedOrgMember: UserAccount + init { root.apply { - nonMember = + val nonMemberBuilder = addUserAccount { username = "non_member" name = "Non Member" - }.self + } + nonMember = nonMemberBuilder.self + nonMemberPersonalOrg = nonMemberBuilder.defaultOrganizationBuilder.self directPermissionUser = addUserAccount { @@ -43,9 +75,62 @@ class PublicProjectsControllerTestData : BaseTestData() { addBaseLanguage() } + otherOrgMember = + addUserAccount { + username = "other_org_member" + name = "Other Org Member" + }.self + + val storedGuestBuilder = + addUserAccount { + username = "stored_guest" + name = "Stored Guest" + } + storedGuest = storedGuestBuilder.self + + otherOrgOwner = + addUserAccount { + username = "other_org_owner" + name = "Other Org Owner" + }.self + + guestWithPermission = + addUserAccount { + username = "guest_with_permission" + name = "Guest With Permission" + }.self + + granularPermissionUser = + addUserAccount { + username = "granular_perm_user" + name = "Granular Perm User" + }.self + + noneOnlyUser = + addUserAccount { + username = "none_only_user" + name = "None Only User" + }.self + + serverAdmin = + addUserAccount { + username = "server_admin" + name = "Server Admin" + role = UserAccount.Role.ADMIN + }.self + otherOrg = addOrganization { name = "Vibrant translators" + }.build { + addRole { + user = this@PublicProjectsControllerTestData.otherOrgMember + type = OrganizationRoleType.MEMBER + } + addRole { + user = this@PublicProjectsControllerTestData.otherOrgOwner + type = OrganizationRoleType.OWNER + } }.self addProject(organizationOwner = otherOrg) { @@ -60,12 +145,156 @@ class PublicProjectsControllerTestData : BaseTestData() { } } + addProject(organizationOwner = otherOrg) { + name = "Other org private project" + }.build { + otherOrgPrivateProject = self + val privateProjectSelf = self + addLanguage { + name = "German" + tag = "de" + originalName = "Deutsch" + privateProjectSelf.baseLanguage = this + } + addPermission { + user = this@PublicProjectsControllerTestData.guestWithPermission + type = ProjectPermissionType.TRANSLATE + } + addPermission { + user = this@PublicProjectsControllerTestData.granularPermissionUser + scopes = arrayOf(Scope.TRANSLATIONS_VIEW) + } + addPermission { + user = this@PublicProjectsControllerTestData.noneOnlyUser + type = ProjectPermissionType.NONE + } + } + + noPublicOrgMember = + addUserAccount { + username = "no_public_org_member" + name = "No Public Org Member" + }.self + + revokedOnlyUser = + addUserAccount { + username = "revoked_only_user" + name = "Revoked Only User" + }.self + + noPublicOrgOwner = + addUserAccount { + username = "no_public_org_owner" + name = "No Public Org Owner" + }.self + + privateOrgMember = + addUserAccount { + username = "private_org_member" + name = "Private Org Member" + }.self + + noPublicOrg = + addOrganization { + name = "Members only outfit" + }.build { + addRole { + user = this@PublicProjectsControllerTestData.noPublicOrgMember + type = OrganizationRoleType.MEMBER + } + addRole { + user = this@PublicProjectsControllerTestData.noPublicOrgOwner + type = OrganizationRoleType.OWNER + } + addRole { + user = this@PublicProjectsControllerTestData.privateOrgMember + type = OrganizationRoleType.MEMBER + } + }.self + + addProject(organizationOwner = noPublicOrg) { + name = "Members only private project" + }.build { + addBaseLanguage() + addPermission { + user = this@PublicProjectsControllerTestData.revokedOnlyUser + type = ProjectPermissionType.NONE + } + } + + noBaseLangOnlyOrg = + addOrganization { + name = "No base lang only org" + }.self + + addProject(organizationOwner = noBaseLangOnlyOrg) { + name = "No base lang only org project" + public = true + }.build { + noBaseLangOnlyOrgProject = self + addBaseLanguage() + clearBaseLanguage() + } + + softDeletedBaseLangOnlyOrg = + addOrganization { + name = "Soft deleted base lang only org" + }.self + + addProject(organizationOwner = softDeletedBaseLangOnlyOrg) { + name = "Soft deleted base lang only org project" + public = true + }.build { + softDeletedBaseLangOnlyOrgProject = self + addBaseLanguage().setDeletedAt() + } + + deletedProjectOnlyOrg = + addOrganization { + name = "Deleted project only org" + }.self + + addProject(organizationOwner = deletedProjectOnlyOrg) { + name = "Deleted project only org project" + public = true + }.build { + deletedProjectOnlyOrgProject = self + addBaseLanguage() + setDeletedAt() + } + + softDeletedOrgMember = + addUserAccount { + username = "soft_deleted_org_member" + name = "Soft Deleted Org Member" + }.self + + softDeletedOrg = + addOrganization { + name = "Soft deleted org" + }.build { + addRole { + user = this@PublicProjectsControllerTestData.softDeletedOrgMember + type = OrganizationRoleType.MEMBER + } + setDeletedAt() + }.self + + addProject(organizationOwner = softDeletedOrg) { + name = "Soft deleted org public project" + public = true + }.build { + softDeletedOrgPublicProject = self + addBaseLanguage() + } + addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { name = "No base language project" public = true }.build { noBaseLanguageProject = self addBaseLanguage() + clearBaseLanguage() } addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { @@ -73,7 +302,7 @@ class PublicProjectsControllerTestData : BaseTestData() { public = true }.build { softDeletedBaseProject = self - addBaseLanguage() + addBaseLanguage().setDeletedAt() } addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { @@ -82,6 +311,7 @@ class PublicProjectsControllerTestData : BaseTestData() { }.build { orgLessProject = self addBaseLanguage() + clearOrganizationOwner() } addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { @@ -90,12 +320,13 @@ class PublicProjectsControllerTestData : BaseTestData() { }.build { deletedPublicProject = self addBaseLanguage() + setDeletedAt() } } } - private fun ProjectBuilder.addBaseLanguage() { - addLanguage { + private fun ProjectBuilder.addBaseLanguage(): LanguageBuilder { + return addLanguage { name = "English" tag = "en" originalName = "English" diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt index 49e739377d1..5aa721fa099 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/PublicProjectsE2eData.kt @@ -2,15 +2,27 @@ package io.tolgee.development.testDataBuilder.data import io.tolgee.development.testDataBuilder.builders.ProjectBuilder -/** - * The inherited BaseTestData project ("Private project") stays non-public so the list endpoint's - * exclusion of private projects can be asserted. - */ class PublicProjectsE2eData( count: Int = 6, + includeForeignOrgProject: Boolean = true, ) : BaseTestData("publicProjectsUser", "Private project") { init { root.apply { + val communityUserBuilder = + addUserAccount { + username = "communityUser" + name = "Community User" + } + + if (includeForeignOrgProject) { + addProject(organizationOwner = communityUserBuilder.defaultOrganizationBuilder.self) { + name = "Community Outsider" + public = true + }.build { + addBaseLanguage() + } + } + listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta").take(count).forEach { suffix -> addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) { name = "Community $suffix" diff --git a/backend/data/src/main/kotlin/io/tolgee/model/enums/OrganizationRoleType.kt b/backend/data/src/main/kotlin/io/tolgee/model/enums/OrganizationRoleType.kt index 5a7d50c38e5..afc8705c1d8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/enums/OrganizationRoleType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/enums/OrganizationRoleType.kt @@ -1,5 +1,9 @@ package io.tolgee.model.enums +/** + * `OrganizationRole.type` persists this enum by ORDINAL — new values must only ever be appended, + * and declaration order can never change. + */ enum class OrganizationRoleType( val isReadOnly: Boolean, ) { diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt index 24f318b5e5f..3db11ccf6ab 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRepository.kt @@ -40,7 +40,7 @@ interface OrganizationRepository : JpaRepository { and r.organization = o and (r.type = :roleType or :roleType is null) left join o.projects p on p.deletedAt is null left join p.permissions perm on perm.user.id = :userId - where (perm is not null or r is not null) + where ((perm is not null and (perm.type <> 'NONE' or perm.type is null)) or r is not null) and (:search is null or (lower(o.name) like lower(concat('%', cast(:search as text), '%')))) and (:exceptOrganizationId is null or (o.id <> :exceptOrganizationId)) and o.deletedAt is null """, @@ -51,7 +51,7 @@ interface OrganizationRepository : JpaRepository { and r.organization = o and (r.type = :roleType or :roleType is null) left join o.projects p on p.deletedAt is null left join p.permissions perm on perm.user.id = :userId - where (perm is not null or r is not null) + where ((perm is not null and (perm.type <> 'NONE' or perm.type is null)) or r is not null) and (:search is null or (lower(o.name) like lower(concat('%', cast(:search as text), '%')))) and (:exceptOrganizationId is null or (o.id <> :exceptOrganizationId)) and o.deletedAt is null """, @@ -72,7 +72,8 @@ interface OrganizationRepository : JpaRepository { left join o.memberRoles mr on mr.user.id = :userId left join o.projects p on p.deletedAt is null left join p.permissions perm on perm.user.id = :userId - where (perm is not null or mr is not null) and o.id <> :exceptOrganizationId and o.deletedAt is null + where ((perm is not null and (perm.type <> 'NONE' or perm.type is null)) or mr is not null) + and o.id <> :exceptOrganizationId and o.deletedAt is null group by mr.id, o.id, bp.id order by mr.id asc nulls last """, @@ -90,7 +91,8 @@ interface OrganizationRepository : JpaRepository { left join o.memberRoles mr on mr.user.id = :userId left join o.projects p on p.deletedAt is null left join p.permissions perm on perm.user.id = :userId - where (perm is not null or mr is not null) and o.id = :organizationId and o.deletedAt is null + where ((perm is not null and (perm.type <> 'NONE' or perm.type is null)) or mr is not null) + and o.id = :organizationId and o.deletedAt is null """, ) fun canUserView( diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt index 240b02bef36..a7df1740a3e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/ProjectRepository.kt @@ -49,14 +49,35 @@ interface ProjectRepository : JpaRepository { or bl.tag = :#{#filters.filterBaseLanguageTag} ) """ + + /** `o.deletedAt is null` is load-bearing — dropping it re-opens guest access to soft-deleted orgs. */ + const val PUBLIC_PROJECT_VISIBILITY = """ + r.public = true and r.deletedAt is null and r.organizationOwner is not null + and r.baseLanguage is not null and bl.deletedAt is null + and o.deletedAt is null + """ + + /** `r.deletedAt`/`o.deletedAt` sit top-level so they also guard the permission branch, which [PUBLIC_PROJECT_VISIBILITY] does not. */ + const val BELOW_MEMBER_ACCESSIBLE_PROJECT = """ + ( + r.deletedAt is null and o.deletedAt is null and ( + ($PUBLIC_PROJECT_VISIBILITY) + or exists ( + select perm.id from Permission perm + where perm.user.id = :userId and perm.project = r and (perm.type <> 'NONE' or perm.type is null) + ) + ) + ) + """ } @Query( - """select r, p, o, role from Project r + """select r, p, o, role from Project r left join fetch Permission p on p.project = r and p.user.id = :userAccountId left join fetch Organization o on r.organizationOwner = o left join fetch OrganizationRole role on role.organization = o and role.user.id = :userAccountId - where (p is not null or (role is not null)) and r.deletedAt is null + where (p is not null or role is not null) + and r.deletedAt is null order by r.name """, ) @@ -68,9 +89,11 @@ interface ProjectRepository : JpaRepository { left join UserAccount ua on ua.id = :userAccountId left join o.basePermission where ( - (p is not null and (p.type <> 'NONE' or p.type is null)) or - (role is not null and (o.basePermission.type <> 'NONE' or o.basePermission.type is null) and p is null) or - ((ua.role = 'ADMIN' or ua.role = 'SUPPORTER') and :organizationId is not null)) + (p is not null and (p.type <> 'NONE' or p.type is null)) or + (role is not null + and (o.basePermission.type <> 'NONE' or o.basePermission.type is null) and p is null) or + ((ua.role = 'ADMIN' or ua.role = 'SUPPORTER') and :organizationId is not null) or + (:organizationId is not null and $PUBLIC_PROJECT_VISIBILITY)) and ( :search is null or (lower(r.name) like lower(concat('%', cast(:search as text), '%')) or lower(o.name) like lower(concat('%', cast(:search as text),'%'))) @@ -108,8 +131,7 @@ interface ProjectRepository : JpaRepository { */ @Query( """$BASE_VIEW_QUERY - where r.public = true and r.deletedAt is null and r.organizationOwner is not null - and r.baseLanguage is not null and bl.deletedAt is null + where $PUBLIC_PROJECT_VISIBILITY and ( :search is null or (lower(r.name) like lower(concat('%', cast(:search as text), '%')) or lower(o.name) like lower(concat('%', cast(:search as text),'%'))) @@ -122,6 +144,29 @@ interface ProjectRepository : JpaRepository { @Param("search") search: String? = null, ): Page + @Query( + """select count(r) > 0 from Project r + left join r.baseLanguage bl + left join r.organizationOwner o + where $PUBLIC_PROJECT_VISIBILITY + and o.id = :organizationId + """, + ) + fun hasPublicProjects(organizationId: Long): Boolean + + @Query( + """select r.id from Project r + left join r.baseLanguage bl + left join r.organizationOwner o + where o.id = :organizationId + and $BELOW_MEMBER_ACCESSIBLE_PROJECT + """, + ) + fun getBelowMemberAccessibleProjectIds( + organizationId: Long, + userId: Long, + ): List + fun findAllByOrganizationOwnerId(organizationOwnerId: Long): List @Query("select p.id from Project p where p.organizationOwner.deletedAt is not null") diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt index 6a06da8e310..800ec7b2a94 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -263,7 +263,8 @@ interface UserAccountRepository : JpaRepository { left join Project r on r.id = :projectId left join ua.permissions p on p.project.id = :projectId left join ua.organizationRoles orl on orl.organization = r.organizationOwner - where r.id = :projectId and (p is not null or orl is not null) + where r.id = :projectId + and (p is not null or orl is not null) and (:exceptUserId is null or ua.id <> :exceptUserId) and ((lower(ua.name) like lower(concat('%', cast(:search as text),'%')) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt index 90b2c53082e..bef5f81c974 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt @@ -18,6 +18,7 @@ import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.repository.OrganizationRepository import io.tolgee.repository.OrganizationRoleRepository import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.project.ProjectService import io.tolgee.service.security.PermissionService import io.tolgee.service.security.UserAccountService import io.tolgee.service.security.UserPreferencesService @@ -41,6 +42,8 @@ class OrganizationRoleService( @param:Lazy private val userPreferencesService: UserPreferencesService, @param:Lazy + private val projectService: ProjectService, + @param:Lazy private val self: OrganizationRoleService, private val cacheManager: CacheManager, ) { @@ -49,6 +52,36 @@ class OrganizationRoleService( organizationId: Long, ) = this.organizationRepository.canUserView(userId, organizationId) + fun checkUserCanViewOrPublic(organizationId: Long) { + if (canUserViewOrPublic(authenticationFacade.authenticatedUser, organizationId)) { + return + } + throw PermissionException(Message.USER_CANNOT_VIEW_THIS_ORGANIZATION) + } + + fun canUserViewOrPublic( + userId: Long, + organizationId: Long, + ): Boolean { + val user = userAccountService.findDto(userId) ?: return false + return canUserViewOrPublic(user, organizationId) + } + + fun canUserViewOrPublic( + user: UserAccountDto, + organizationId: Long, + ): Boolean = user.isSupporterOrAdmin() || canUserViewStrictOrPublic(user.id, organizationId) + + fun canUserViewAtLeastMember( + user: UserAccountDto, + organizationId: Long, + ): Boolean = user.isSupporterOrAdmin() || hasAnyOrganizationRole(user.id, organizationId) + + fun canUserViewStrictOrPublic( + userId: Long, + organizationId: Long, + ): Boolean = canUserViewStrict(userId, organizationId) || projectService.hasPublicProjects(organizationId) + fun checkUserCanView(organizationId: Long) { checkUserCanView( authenticationFacade.authenticatedUser, @@ -70,22 +103,6 @@ class OrganizationRoleService( } } - fun canUserView( - userId: Long, - organizationId: Long, - ): Boolean { - val userAccountDto = - userAccountService.findDto(userId) - ?: return false - - return canUserView(userAccountDto, organizationId) - } - - fun canUserView( - user: UserAccountDto, - organizationId: Long, - ) = user.isSupporterOrAdmin() || this.organizationRepository.canUserView(user.id, organizationId) - /** * Verifies the user has a role equal or higher than a given role. * @@ -154,10 +171,7 @@ class OrganizationRoleService( fun hasAnyOrganizationRole( userId: Long, organizationId: Long, - ): Boolean { - val role = self.getDto(organizationId, userId).type - return role != null - } + ): Boolean = findType(userId, organizationId) != null fun isUserOwner( userId: Long, diff --git a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt index b370548bcbd..9d5baa580ae 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/project/ProjectService.kt @@ -489,6 +489,19 @@ class ProjectService( return projects.map { ProjectWithLanguagesView.fromProjectView(it, null) } } + @Transactional(readOnly = true) + fun hasPublicProjects(organizationId: Long): Boolean { + return projectRepository.hasPublicProjects(organizationId) + } + + @Transactional(readOnly = true) + fun getBelowMemberAccessibleProjectIds( + organizationId: Long, + userId: Long, + ): List { + return projectRepository.getBelowMemberAccessibleProjectIds(organizationId, userId) + } + @CacheEvict(cacheNames = [Caches.PROJECTS], allEntries = true) fun saveAll(projects: Collection): MutableList = projectRepository.saveAll(projects) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt index ffe2830038d..a84adc32100 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt @@ -25,8 +25,10 @@ import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.branching.BranchService import io.tolgee.service.label.LabelService import io.tolgee.service.language.LanguageService +import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.project.ProjectFeatureGuard import io.tolgee.service.project.ProjectFeatureRegistry +import io.tolgee.service.project.ProjectService import io.tolgee.service.task.ITaskService import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy @@ -60,6 +62,28 @@ class SecurityService( @Autowired private lateinit var projectFeatureGuard: ProjectFeatureGuard + @set:Autowired + @set:Lazy + lateinit var organizationRoleService: OrganizationRoleService + + @set:Autowired + @set:Lazy + lateinit var projectService: ProjectService + + fun getAccessibleProjectIdsInOrganization( + organizationId: Long, + requestedProjectIds: List?, + ): List? { + val user = authenticationFacade.authenticatedUser + if (organizationRoleService.canUserViewAtLeastMember(user, organizationId)) { + return requestedProjectIds + } + val accessibleIds = projectService.getBelowMemberAccessibleProjectIds(organizationId, user.id) + if (requestedProjectIds == null) return accessibleIds + val accessibleSet = accessibleIds.toSet() + return requestedProjectIds.filter { it in accessibleSet } + } + fun checkAnyProjectPermission(projectId: Long) { if ( getProjectPermissionScopesNoApiKey(projectId).isNullOrEmpty() && diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt index 39a8a1ee0d8..d899fe554d9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserPreferencesService.kt @@ -8,6 +8,7 @@ import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.organization.OrganizationService import io.tolgee.util.tryUntilItDoesntBreakConstraint +import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service @Service @@ -16,6 +17,7 @@ class UserPreferencesService( private val userPreferencesRepository: UserPreferencesRepository, private val userAccountService: UserAccountService, private val organizationService: OrganizationService, + @param:Lazy private val organizationRoleService: OrganizationRoleService, ) { fun setLanguage( @@ -99,10 +101,7 @@ class UserPreferencesService( fun refreshPreferredOrganization(preferences: UserPreferences): Organization? { val canUserView = preferences.preferredOrganization?.let { po -> - organizationRoleService.canUserView( - preferences.userAccount.id, - po.id, - ) + organizationRoleService.canUserViewOrPublic(preferences.userAccount.id, po.id) } ?: false if (!canUserView) { diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/OrganizationRoleTypeTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/OrganizationRoleTypeTest.kt new file mode 100644 index 00000000000..1d195841298 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/OrganizationRoleTypeTest.kt @@ -0,0 +1,15 @@ +package io.tolgee.unit + +import io.tolgee.model.enums.OrganizationRoleType +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test + +class OrganizationRoleTypeTest { + @Test + fun `declaration order is pinned because the entity persists the type by ordinal`() { + OrganizationRoleType.entries + .map { it.name } + .assert + .containsExactly("MEMBER", "OWNER", "MAINTAINER") + } +} diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt index 1748c86f274..745bb072697 100644 --- a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/PublicProjectsE2eDataController.kt @@ -18,6 +18,6 @@ class PublicProjectsE2eDataController( @GetMapping(value = ["/generate-few"]) @Transactional fun generateFew(): StandardTestDataResult { - return generatingService.generate(PublicProjectsE2eData(count = 5).root) + return generatingService.generate(PublicProjectsE2eData(count = 5, includeForeignOrgProject = false).root) } } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt index 73522b37d4d..3381068a034 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt @@ -36,7 +36,8 @@ import org.springframework.web.method.HandlerMethod /** * This interceptor performs an authorization step to access organization-related endpoints. - * By default, the user needs to have access to at least 1 project on the target org to access it. + * By default it enforces the org view floor (see [OrganizationRoleService.canUserViewStrictOrPublic]). + * Anything beyond viewing must require a role via `@RequiresOrganizationRole`. */ @Component class OrganizationAuthorizationInterceptor( @@ -71,7 +72,8 @@ class OrganizationAuthorizationInterceptor( requiredRole ?: "read-only", ) - if (!organizationRoleService.canUserViewStrict(userId, organization.id)) { + // raw floor, not canUserViewOrPublic: admin/supporter access must fall through to canBypass below to stay audit-logged + if (!organizationRoleService.canUserViewStrictOrPublic(userId, organization.id)) { if (!canBypass(request, handler)) { logger.debug( "Rejecting access to org#{} for user#{} - No view permissions", diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt index 9cf3e5569ca..4980b9c7b52 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt @@ -36,6 +36,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post @@ -120,14 +122,14 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `it hides the organization if the user cannot see it`() { Mockito - .`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) + .`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)) .thenReturn(false) mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsNotFound mockMvc.perform(get("/v2/organizations/1337/requires-owner")).andIsNotFound Mockito - .`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) + .`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)) .thenReturn(true) mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsOk @@ -136,7 +138,7 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `rejects access if the user does not have a sufficiently high role`() { Mockito - .`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) + .`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)) .thenReturn(true) Mockito .`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)) @@ -153,7 +155,7 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `it allows supporter to bypass checks for read-only organization endpoints`() { - Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(false) Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) performReadOnlyRequests { all -> all.andIsOk } @@ -161,7 +163,7 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `it does not let supporter bypass checks for write organization endpoints`() { - Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(false) Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.SUPPORTER) performWriteRequests { all -> all.andIsForbidden } @@ -169,13 +171,34 @@ class OrganizationAuthorizationInterceptorTest { @Test fun `it allows admin to access any endpoint`() { - Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)).thenReturn(false) + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(false) Mockito.`when`(userAccount.role).thenReturn(UserAccount.Role.ADMIN) performReadOnlyRequests { all -> all.andIsOk } performWriteRequests { all -> all.andIsOk } } + @Test + fun `the interceptor consults the raw floor, never the admin-aware variant`() { + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(true) + + mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsOk + + verify(organizationRoleService).canUserViewStrictOrPublic(1337L, 1337L) + verify(organizationRoleService, never()).canUserViewOrPublic(any(), any()) + } + + @Test + fun `the view floor grants default endpoints but not role-required ones`() { + Mockito.`when`(organizationRoleService.canUserViewStrictOrPublic(1337L, 1337L)).thenReturn(true) + Mockito + .`when`(organizationRoleService.isUserOfRole(1337L, 1337L, OrganizationRoleType.OWNER)) + .thenReturn(false) + + mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsOk + mockMvc.perform(get("/v2/organizations/1337/requires-owner")).andIsForbidden + } + private fun performReadOnlyRequests(condition: (ResultActions) -> Unit) { // GET method mockMvc.perform(get("/v2/organizations/1337/default-perms")).andSatisfies(condition) diff --git a/e2e/cypress/e2e/projects/communityPreferredOrganization.cy.ts b/e2e/cypress/e2e/projects/communityPreferredOrganization.cy.ts new file mode 100644 index 00000000000..16abd654201 --- /dev/null +++ b/e2e/cypress/e2e/projects/communityPreferredOrganization.cy.ts @@ -0,0 +1,93 @@ +import { HOST } from '../../common/constants'; +import { login } from '../../common/apiCalls/common'; +import { publicProjectsData } from '../../common/apiCalls/testData/testData'; +import { + assertSwitchedToOrganization, + gcy, + gcyAdvanced, + switchToOrganization, +} from '../../common/shared'; +import { waitForGlobalLoading } from '../../common/loading'; + +describe('Community preferred organization', () => { + let organizations: Record; + + beforeEach(() => { + publicProjectsData.clean(); + publicProjectsData.generateStandard().then((res) => { + organizations = {}; + res.body.organizations.forEach((org) => { + organizations[org.name] = org; + }); + }); + login('communityUser'); + }); + + afterEach(() => { + publicProjectsData.clean(); + }); + + const openPublicProject = () => { + cy.visit(`${HOST}/public-projects`); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').contains('Community Alpha').click(); + cy.url().should('match', /\/projects\/[0-9]+/); + waitForGlobalLoading(); + }; + + it('switches a guest to the owning organization and shows its public projects', () => { + openPublicProject(); + gcy('notistack-snackbar').should('not.exist'); + assertSwitchedToOrganization('publicProjectsUser'); + + cy.visit(`${HOST}/`); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').should('have.length', 6); + cy.contains('Community Outsider').should('not.exist'); + cy.contains('Private project').should('not.exist'); + gcy('global-plus-button').should('not.exist'); + gcy('project-list-more-button').should('not.exist'); + }); + + it('restores the full member experience after switching back to the own organization', () => { + openPublicProject(); + assertSwitchedToOrganization('publicProjectsUser'); + + cy.visit(`${HOST}/`); + waitForGlobalLoading(); + switchToOrganization('Community User'); + waitForGlobalLoading(); + gcy('global-plus-button').should('exist'); + gcy('dashboard-projects-list-item').should('have.length', 1); + gcy('project-list-more-button').should('exist'); + }); + + it('keeps the project page usable when the preferred-organization switch fails', () => { + cy.intercept('PUT', '**/v2/user-preferences/set-preferred-organization/*', { + statusCode: 403, + body: { code: 'operation_not_permitted' }, + }); + openPublicProject(); + cy.url().should('match', /\/projects\/[0-9]+/); + gcy('global-base-view-content').should('exist'); + }); + + it('shows glossaries but hides translation memories in the foreign org settings', () => { + openPublicProject(); + cy.visit( + `${HOST}/organizations/${organizations['publicProjectsUser'].slug}/profile` + ); + waitForGlobalLoading(); + gcyAdvanced({ value: 'settings-menu-item', item: 'profile' }).should( + 'be.visible' + ); + gcyAdvanced({ value: 'settings-menu-item', item: 'glossaries' }).should( + 'be.visible' + ); + gcyAdvanced({ + value: 'settings-menu-item', + item: 'translation-memories', + }).should('not.exist'); + gcy('organization-profile-leave-button').should('be.disabled'); + }); +}); diff --git a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts index ede478f4c7d..d97ea3f8194 100644 --- a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts +++ b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts @@ -212,8 +212,8 @@ describe('Community projects list content', () => { }); it('lists public projects across orgs with the public badge, hiding private ones', () => { - gcy('dashboard-projects-list-item').should('have.length', 6); - gcy('project-list-public-badge').should('have.length', 6); + gcy('dashboard-projects-list-item').should('have.length', 7); + gcy('project-list-public-badge').should('have.length', 7); cy.contains('Community Alpha').should('be.visible'); cy.contains('Community Zeta').should('be.visible'); cy.contains('Private project').should('not.exist'); @@ -232,9 +232,9 @@ describe('Community projects list content', () => { waitForGlobalLoading(); gcy('dashboard-projects-list-item').should('have.length', 1); gcy('global-list-search').find('input').clear(); - // The length-6 assertion must come first: it retries until the debounced cleared-search + // The list-length assertion must come first: it retries until the debounced cleared-search // refetch lands, so the focus check below runs only after the field has (or hasn't) remounted. - gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('dashboard-projects-list-item').should('have.length', 7); gcy('global-list-search').find('input').should('be.focused'); }); }); diff --git a/e2e/cypress/e2e/projects/publicProjects.cy.ts b/e2e/cypress/e2e/projects/publicProjects.cy.ts index bfa1d838ca8..bbad2974746 100644 --- a/e2e/cypress/e2e/projects/publicProjects.cy.ts +++ b/e2e/cypress/e2e/projects/publicProjects.cy.ts @@ -32,8 +32,8 @@ describe('Public projects view', () => { it('lists public projects with the public badge + org and hides private ones', () => { publicProjectsData.generateStandard(); visit(); - gcy('dashboard-projects-list-item').should('have.length', 6); - gcy('project-list-public-badge').should('have.length', 6); + gcy('dashboard-projects-list-item').should('have.length', 7); + gcy('project-list-public-badge').should('have.length', 7); gcy('global-search-field').should('exist'); cy.contains('Community Alpha').should('be.visible'); cy.contains('Community Zeta').should('be.visible'); @@ -53,7 +53,7 @@ describe('Public projects view', () => { gcy('global-search-field').should('exist'); gcy('global-search-field').find('input').clear(); - gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('dashboard-projects-list-item').should('have.length', 7); gcy('global-search-field').should('exist'); }); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index a4ff22aa7ac..18773b2c844 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -253,6 +253,7 @@ declare namespace DataCy { "global-search-field": true; "global-search-field-clear": true; "global-user-menu-button": true; + "glossaries": true; "glossaries-empty-add-button": true; "glossaries-list-more-button": true; "glossary-batch-delete-button": true; @@ -538,6 +539,7 @@ declare namespace DataCy { "plan-limit-dialog-close": true; "plan-limit-exceeded-popover": true; "plan_seat_limit_exceeded_while_accepting_invitation_message": true; + "profile": true; "project-ai-prompt-dialog-description-input": true; "project-ai-prompt-dialog-save": true; "project-base-language-tm-conflict-confirm": true; @@ -868,6 +870,7 @@ declare namespace DataCy { "translation-label-add": true; "translation-label-control": true; "translation-label-delete": true; + "translation-memories": true; "translation-memories-empty-add-button": true; "translation-memories-list-more-button": true; "translation-memory-delete-button": true; diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt index b8c61789fd5..5d2edc60900 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryController.kt @@ -35,6 +35,7 @@ import org.springframework.data.web.PagedResourcesAssembler import org.springframework.hateoas.CollectionModel import org.springframework.hateoas.PagedModel import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -46,6 +47,7 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController +@CrossOrigin(origins = ["*"]) @RequestMapping("/v2/organizations/{organizationId:[0-9]+}") @Tag(name = "Glossary") class GlossaryController( @@ -176,6 +178,6 @@ class GlossaryController( ): CollectionModel { val organization = organizationHolder.organization val glossary = glossaryService.get(organization.id, glossaryId) - return simpleProjectModelAssembler.toCollectionModel(glossary.assignedProjects) + return simpleProjectModelAssembler.toCollectionModel(glossaryService.getAssignedProjectsForCurrentUser(glossary)) } } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt index cfc855c752d..80494414f91 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryRepository.kt @@ -1,7 +1,9 @@ package io.tolgee.ee.repository.glossary import io.tolgee.ee.data.glossary.GlossaryWithStats +import io.tolgee.model.Project import io.tolgee.model.glossary.Glossary +import io.tolgee.repository.ProjectRepository import org.springframework.context.annotation.Lazy import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -13,6 +15,20 @@ import org.springframework.stereotype.Repository @Repository @Lazy interface GlossaryRepository : JpaRepository { + companion object { + const val ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE = ProjectRepository.BELOW_MEMBER_ACCESSIBLE_PROJECT + + const val BELOW_MEMBER_ACCESSIBLE = """ + exists ( + select r.id from Glossary g2 + join g2.assignedProjects r + left join r.baseLanguage bl + join r.organizationOwner o + where g2.id = g.id and $ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE + ) + """ + } + @Query( """ from Glossary @@ -26,6 +42,21 @@ interface GlossaryRepository : JpaRepository { glossaryId: Long, ): Glossary? + @Query( + """ + select g from Glossary g + where g.organizationOwner.id = :organizationId + and g.organizationOwner.deletedAt is null + and g.id = :glossaryId + and ($BELOW_MEMBER_ACCESSIBLE) + """, + ) + fun findBelowMemberAccessible( + organizationId: Long, + glossaryId: Long, + userId: Long?, + ): Glossary? + @Query( """ from Glossary @@ -49,6 +80,22 @@ interface GlossaryRepository : JpaRepository { search: String?, ): Page + @Query( + """ + select g from Glossary g + where g.organizationOwner.id = :organizationId + and g.organizationOwner.deletedAt is null + and (lower(g.name) like lower(concat('%', coalesce(:search, ''), '%')) or :search is null) + and ($BELOW_MEMBER_ACCESSIBLE) + """, + ) + fun findByOrganizationIdBelowMemberPaged( + organizationId: Long, + userId: Long?, + pageable: Pageable, + search: String?, + ): Page + @Query( """ select g.id as id, @@ -72,13 +119,44 @@ interface GlossaryRepository : JpaRepository { @Query( """ - select gp.project_id - from glossary_project gp - where gp.glossary_id = :glossaryId + select g.id as id, + g.name as name, + g.baseLanguageTag as baseLanguageTag, + min(r.name) as firstAssignedProjectName, + count(r) as assignedProjectsCount + from Glossary g + join g.assignedProjects r + left join r.baseLanguage bl + join r.organizationOwner o + where g.organizationOwner.id = :organizationId + and g.organizationOwner.deletedAt is null + and (lower(g.name) like lower(concat('%', coalesce(:search, ''), '%')) or :search is null) + and $ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE + group by g.id + """, + ) + fun findByOrganizationIdWithStatsBelowMemberPaged( + organizationId: Long, + userId: Long?, + pageable: Pageable, + search: String?, + ): Page + + @Query( + """ + select r from Glossary g + join g.assignedProjects r + left join r.baseLanguage bl + join r.organizationOwner o + where g.id = :glossaryId + and $ASSIGNED_PROJECT_BELOW_MEMBER_ACCESSIBLE + order by r.name """, - nativeQuery = true, ) - fun findAssignedProjectsIdsByGlossaryId(glossaryId: Long): Set + fun findBelowMemberAccessibleAssignedProjects( + glossaryId: Long, + userId: Long?, + ): List @Query( """ diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt index c1746bde38d..92623734e05 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/repository/glossary/GlossaryTermRepository.kt @@ -12,18 +12,8 @@ import org.springframework.stereotype.Repository @Repository @Lazy interface GlossaryTermRepository : JpaRepository { - @Query( - """ - from GlossaryTerm - where glossary.organizationOwner.id = :organizationId - and glossary.organizationOwner.deletedAt is null - and glossary.id = :glossaryId - and id = :id - """, - ) - fun find( - organizationId: Long, - glossaryId: Long, + fun findByGlossaryAndId( + glossary: Glossary, id: Long, ): GlossaryTerm? diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryExportService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryExportService.kt index ddd47062b17..871980fc698 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryExportService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryExportService.kt @@ -35,14 +35,14 @@ class GlossaryExportService( } private fun getOrganizationLanguageTagsForExport(glossary: Glossary): Set { - val assignedProjectIds = glossaryService.getAssignedProjectsIds(glossary) + val assignedProjectIds = glossaryService.getAssignedProjectsForCurrentUser(glossary).map { it.id } if (assignedProjectIds.isEmpty()) { return languageService.getTagsByOrganization(glossary.organizationOwner.id) } return languageService.getTagsByOrganizationAndProjectIds( glossary.organizationOwner.id, - assignedProjectIds.toList(), + assignedProjectIds, ) } diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt index 4af3f052372..b59992113c4 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryService.kt @@ -10,7 +10,9 @@ import io.tolgee.exceptions.NotFoundException import io.tolgee.model.Organization import io.tolgee.model.Project import io.tolgee.model.glossary.Glossary +import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.GlossaryCleanupService +import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.project.ProjectService import jakarta.transaction.Transactional import org.springframework.context.annotation.Primary @@ -25,6 +27,8 @@ class GlossaryService( private val glossaryTermTranslationService: GlossaryTermTranslationService, private val projectService: ProjectService, private val currentDateProvider: CurrentDateProvider, + private val authenticationFacade: AuthenticationFacade, + private val organizationRoleService: OrganizationRoleService, ) : GlossaryCleanupService { fun findAll(organizationId: Long): List { return glossaryRepository.findByOrganizationId(organizationId) @@ -35,6 +39,9 @@ class GlossaryService( pageable: Pageable, search: String?, ): Page { + if (isBelowMemberReader(organizationId)) { + return glossaryRepository.findByOrganizationIdBelowMemberPaged(organizationId, currentUserId(), pageable, search) + } return glossaryRepository.findByOrganizationIdPaged(organizationId, pageable, search) } @@ -50,6 +57,14 @@ class GlossaryService( pageable: Pageable, search: String?, ): Page { + if (isBelowMemberReader(organizationId)) { + return glossaryRepository.findByOrganizationIdWithStatsBelowMemberPaged( + organizationId, + currentUserId(), + pageable, + search, + ) + } return glossaryRepository.findByOrganizationIdWithStatsPaged(organizationId, pageable, search) } @@ -57,9 +72,19 @@ class GlossaryService( organizationId: Long, glossaryId: Long, ): Glossary? { + if (isBelowMemberReader(organizationId)) { + return glossaryRepository.findBelowMemberAccessible(organizationId, glossaryId, currentUserId()) + } return glossaryRepository.find(organizationId, glossaryId) } + private fun isBelowMemberReader(organizationId: Long): Boolean { + val user = authenticationFacade.authenticatedUserOrNull ?: return true + return !organizationRoleService.canUserViewAtLeastMember(user, organizationId) + } + + private fun currentUserId(): Long? = authenticationFacade.authenticatedUserOrNull?.id + fun get( organizationId: Long, glossaryId: Long, @@ -131,8 +156,11 @@ class GlossaryService( glossaryRepository.delete(glossary) } - fun getAssignedProjectsIds(glossary: Glossary): Set { - return glossaryRepository.findAssignedProjectsIdsByGlossaryId(glossary.id) + fun getAssignedProjectsForCurrentUser(glossary: Glossary): Collection { + if (isBelowMemberReader(glossary.organizationOwner.id)) { + return glossaryRepository.findBelowMemberAccessibleAssignedProjects(glossary.id, currentUserId()) + } + return glossary.assignedProjects } @Transactional diff --git a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt index 283f68e9622..0fa450f7e65 100644 --- a/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt +++ b/ee/backend/app/src/main/kotlin/io/tolgee/ee/service/glossary/GlossaryTermService.kt @@ -38,7 +38,8 @@ class GlossaryTermService( glossaryId: Long, termId: Long, ): GlossaryTerm? { - return glossaryTermRepository.find(organizationId, glossaryId, termId) + val glossary = glossaryService.find(organizationId, glossaryId) ?: return null + return glossaryTermRepository.findByGlossaryAndId(glossary, termId) } fun findAll( diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AuthProviderChangeControllerEeTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AuthProviderChangeControllerEeTest.kt index 9fedd98f1cf..ae7bb6beaad 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AuthProviderChangeControllerEeTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/AuthProviderChangeControllerEeTest.kt @@ -7,7 +7,10 @@ import io.tolgee.fixtures.andAssertThatJson import io.tolgee.fixtures.andIsNoContent import io.tolgee.fixtures.andIsNotFound import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -92,6 +95,30 @@ class AuthProviderChangeControllerEeTest : AuthorizedControllerTest() { } } + @Test + fun `accepting organizations sso keeps an existing organization role`() { + executeInNewTransaction { + organizationRoleService.grantRoleToUser( + userAccountService.get(testData.userChangeNoneToSsoOrganizations.id), + organizationService.get(testData.organization.id), + OrganizationRoleType.MEMBER, + ) + } + userAccount = testData.userChangeNoneToSsoOrganizations + performAuthPost("/v2/auth-provider/change", mapOf("id" to testData.changeNoneToSsoOrganizations.identifier)).andIsOk + executeInNewTransaction { + organizationRoleService + .findType(testData.userChangeNoneToSsoOrganizations.id, testData.organization.id) + .assert + .isEqualTo(OrganizationRoleType.MEMBER) + organizationRoleService + .getManagedBy(testData.userChangeNoneToSsoOrganizations.id) + ?.id + .assert + .isEqualTo(testData.organization.id) + } + } + @Test fun `accepts change google to global sso`() { userAccount = testData.userChangeGoogleToSsoGlobal diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt new file mode 100644 index 00000000000..d8f8448b796 --- /dev/null +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/api/v2/controllers/glossary/GlossaryGuestAccessTest.kt @@ -0,0 +1,217 @@ +package io.tolgee.ee.api.v2.controllers.glossary + +import io.tolgee.constants.Feature +import io.tolgee.development.testDataBuilder.data.GlossaryGuestAccessTestData +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.ee.service.glossary.GlossaryService +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotFound +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.node +import io.tolgee.model.UserAccount +import io.tolgee.testing.AuthorizedControllerTest +import io.tolgee.testing.assert +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +@AutoConfigureMockMvc +class GlossaryGuestAccessTest : AuthorizedControllerTest() { + @Autowired + private lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + @Autowired + private lateinit var glossaryService: GlossaryService + + lateinit var testData: GlossaryGuestAccessTestData + + @BeforeEach + fun setup() { + enabledFeaturesProvider.forceEnabled = setOf(Feature.GLOSSARY) + testData = GlossaryGuestAccessTestData() + testDataService.saveTestData(testData.root) + } + + @AfterEach + fun cleanup() { + testDataService.cleanTestData(testData.root) + userAccount = null + enabledFeaturesProvider.forceEnabled = null + } + + @Test + fun `guest lists only glossaries assigned to accessible projects`() { + assertGuestList( + testData.virtualGuest, + "Mixed assignment glossary", + "Public project glossary", + ) + assertGuestList( + testData.storedGuest, + "Mixed assignment glossary", + "Public project glossary", + ) + assertGuestList( + testData.noneOnlyUser, + "Mixed assignment glossary", + "Public project glossary", + ) + } + + @Test + fun `guest stats cover only accessible projects of a mixed-assignment glossary`() { + userAccount = testData.virtualGuest + performAuthGet( + "/v2/organizations/${testData.organization.id}/glossaries-with-stats?sort=name&sort=id", + ).andIsOk.andAssertThatJson { + node("_embedded.glossaries") { + isArray.hasSize(2) + node("[0].name").isEqualTo("Mixed assignment glossary") + node("[0].assignedProjectsCount").isEqualTo(1) + node("[0].firstAssignedProjectName").isEqualTo("Guest visible public project") + node("[1].name").isEqualTo("Public project glossary") + } + } + } + + @Test + fun `guest sees only accessible assigned projects of a mixed-assignment glossary`() { + userAccount = testData.virtualGuest + performAuthGet( + "/v2/organizations/${testData.organization.id}/glossaries/${testData.mixedGlossary.id}/assigned-projects", + ).andIsOk.andAssertThatJson { + node("_embedded.projects") { + isArray.hasSize(1) + node("[0].name").isEqualTo("Guest visible public project") + } + } + } + + @Test + fun `direct project permission holder reads glossaries of accessible projects`() { + assertGuestList( + testData.directPermissionUser, + "Mixed assignment glossary", + "Private project glossary", + "Public project glossary", + ) + } + + @Test + fun `guest can read an accessible glossary but not a members-only one`() { + userAccount = testData.virtualGuest + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.publicGlossary.id}") + .andIsOk + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.privateGlossary.id}") + .andIsNotFound + userAccount = testData.storedGuest + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/${testData.privateGlossary.id}") + .andIsNotFound + } + + @Test + fun `guest terms access follows the glossary accessibility`() { + userAccount = testData.virtualGuest + performAuthGet( + "/v2/organizations/${testData.organization.id}/glossaries/${testData.publicGlossary.id}/terms", + ).andIsOk + performAuthGet( + "/v2/organizations/${testData.organization.id}/glossaries/${testData.privateGlossary.id}/terms", + ).andIsNotFound + } + + @Test + fun `glossaries of soft-deleted projects or base languages stay hidden from guests`() { + userAccount = testData.virtualGuest + val base = "/v2/organizations/${testData.organization.id}/glossaries" + performAuthGet("$base/${testData.softDeletedProjectGlossary.id}").andIsNotFound + performAuthGet("$base/${testData.softDeletedProjectGlossary.id}/terms").andIsNotFound + performAuthGet("$base/${testData.softDeletedProjectGlossary.id}/assigned-projects").andIsNotFound + performAuthGet("$base/${testData.softDeletedBaseLangGlossary.id}").andIsNotFound + performAuthGet("$base/${testData.softDeletedBaseLangGlossary.id}/terms").andIsNotFound + performAuthGet("$base/${testData.softDeletedBaseLangGlossary.id}/assigned-projects").andIsNotFound + } + + @Test + fun `guest single-term reads follow the glossary accessibility`() { + userAccount = testData.virtualGuest + val publicTermPath = + "/v2/organizations/${testData.organization.id}" + + "/glossaries/${testData.publicGlossary.id}/terms/${testData.publicGlossaryTerm.id}" + val privateTermPath = + "/v2/organizations/${testData.organization.id}" + + "/glossaries/${testData.privateGlossary.id}/terms/${testData.privateGlossaryTerm.id}" + performAuthGet(publicTermPath).andIsOk + performAuthGet("$publicTermPath/translations/en").andIsOk + performAuthGet(privateTermPath).andIsNotFound + performAuthGet("$privateTermPath/translations/en").andIsNotFound + } + + @Test + fun `an unauthenticated context takes the guest-restricted path`() { + executeInNewTransaction { + glossaryService.find(testData.organization.id, testData.privateGlossary.id).assert.isNull() + glossaryService.find(testData.organization.id, testData.publicGlossary.id).assert.isNotNull() + } + } + + @Test + fun `guest cannot create a glossary`() { + userAccount = testData.storedGuest + performAuthPost( + "/v2/organizations/${testData.organization.id}/glossaries", + mapOf("name" to "Nope", "baseLanguageTag" to "en"), + ).andIsForbidden + } + + @Test + fun `member sees all glossaries`() { + userAccount = testData.user + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries") + .andIsOk + .andAssertThatJson { + node("_embedded.glossaries").isArray.hasSize(6) + } + } + + @Test + fun `guest export omits a private co-assigned project's languages`() { + userAccount = testData.virtualGuest + exportLanguageHeaders(testData.mixedGlossary.id).assert.doesNotContain("de") + + userAccount = testData.user + exportLanguageHeaders(testData.mixedGlossary.id).assert.contains("de") + } + + private fun exportLanguageHeaders(glossaryId: Long): List { + val csv = + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries/$glossaryId/export") + .andIsOk + .andReturn() + .response.contentAsString + val headers = csv.lines()[0].split(",").map { it.trim().removeSurrounding("\"") } + return headers.drop(6) + } + + private fun assertGuestList( + user: UserAccount, + vararg expectedNames: String, + ) { + userAccount = user + performAuthGet("/v2/organizations/${testData.organization.id}/glossaries?sort=name&sort=id") + .andIsOk + .andAssertThatJson { + node("_embedded.glossaries") { + isArray.hasSize(expectedNames.size) + expectedNames.forEachIndexed { index, name -> + node("[$index].name").isEqualTo(name) + } + } + } + } +} diff --git a/webapp/src/component/layout/BaseSettingsView/SettingsMenu.tsx b/webapp/src/component/layout/BaseSettingsView/SettingsMenu.tsx index dc0882d9f66..249f77ad239 100644 --- a/webapp/src/component/layout/BaseSettingsView/SettingsMenu.tsx +++ b/webapp/src/component/layout/BaseSettingsView/SettingsMenu.tsx @@ -27,6 +27,7 @@ export const SettingsMenu: React.FC> = ({ matchAsPrefix={true} linkTo={item.link} text={item.label} + dataCyItem={item['data-cy']} /> ))} diff --git a/webapp/src/component/layout/BaseSettingsView/SettingsMenuItem.tsx b/webapp/src/component/layout/BaseSettingsView/SettingsMenuItem.tsx index 8bd29270a04..6c4e794e8db 100644 --- a/webapp/src/component/layout/BaseSettingsView/SettingsMenuItem.tsx +++ b/webapp/src/component/layout/BaseSettingsView/SettingsMenuItem.tsx @@ -34,6 +34,7 @@ type Props = { selected?: boolean; matchAsPrefix?: boolean; hidden?: boolean; + dataCyItem?: string; }; export const SettingsMenuItem: React.FC> = ({ @@ -42,6 +43,7 @@ export const SettingsMenuItem: React.FC> = ({ selected, matchAsPrefix, hidden, + dataCyItem, }) => { const match = useLocation(); @@ -57,6 +59,7 @@ export const SettingsMenuItem: React.FC> = ({ aria-label={text} to={linkTo as string} data-cy="settings-menu-item" + data-cy-item={dataCyItem} tabIndex={hidden ? -1 : undefined} className={clsx('link', { selected: isSelected })} > diff --git a/webapp/src/fixtures/__tests__/organizationRole.test.ts b/webapp/src/fixtures/__tests__/organizationRole.test.ts new file mode 100644 index 00000000000..298899820f2 --- /dev/null +++ b/webapp/src/fixtures/__tests__/organizationRole.test.ts @@ -0,0 +1,14 @@ +import { isAtLeastMemberOrgRole } from '../organizationRole'; + +describe('isAtLeastMemberOrgRole', () => { + it('treats MEMBER and above as members', () => { + expect(isAtLeastMemberOrgRole('MEMBER')).toBe(true); + expect(isAtLeastMemberOrgRole('MAINTAINER')).toBe(true); + expect(isAtLeastMemberOrgRole('OWNER')).toBe(true); + }); + + it('does not treat a missing role as a member', () => { + expect(isAtLeastMemberOrgRole(undefined)).toBe(false); + expect(isAtLeastMemberOrgRole(null)).toBe(false); + }); +}); diff --git a/webapp/src/fixtures/organizationRole.ts b/webapp/src/fixtures/organizationRole.ts new file mode 100644 index 00000000000..1e2501c265b --- /dev/null +++ b/webapp/src/fixtures/organizationRole.ts @@ -0,0 +1,8 @@ +import { components } from 'tg.service/apiSchema.generated'; + +type OrganizationRole = + components['schemas']['PrivateOrganizationModel']['currentUserRole']; + +export const isAtLeastMemberOrgRole = ( + role: OrganizationRole | undefined | null +): boolean => Boolean(role); diff --git a/webapp/src/globalContext/useInitialDataService.ts b/webapp/src/globalContext/useInitialDataService.ts index 8fe4c566331..eb007e4915b 100644 --- a/webapp/src/globalContext/useInitialDataService.ts +++ b/webapp/src/globalContext/useInitialDataService.ts @@ -156,6 +156,9 @@ export const useInitialDataService = () => { const data = await preferredOrganizationLoadable.mutateAsync({}); setQuickStart(data.quickStart); setOrganization(data); + } catch { + // the global API error handler already surfaced the failure; callers fire-and-forget + // this on every project open, so a rethrow would be an unhandled rejection } finally { setOrganizationLoading(false); } diff --git a/webapp/src/globalContext/useOrganizationUsageService.ts b/webapp/src/globalContext/useOrganizationUsageService.ts index d98dab1910d..3f92ee8f8f7 100644 --- a/webapp/src/globalContext/useOrganizationUsageService.ts +++ b/webapp/src/globalContext/useOrganizationUsageService.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { components } from 'tg.service/apiSchema.generated'; import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; type OrganizationModel = components['schemas']['OrganizationModel']; type UsageModel = components['schemas']['PublicUsageModel']; @@ -14,7 +15,9 @@ export const useOrganizationUsageService = ({ organization, enabled, }: Props) => { - const isOrganizationMember = Boolean(organization?.currentUserRole); + const isOrganizationMember = isAtLeastMemberOrgRole( + organization?.currentUserRole + ); const [organizationUsage, setOrganizationUsage] = useState< UsageModel | undefined >(undefined); diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index ed1fd5be77b..e04b695b952 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -5192,6 +5192,11 @@ export interface components { )[]; /** Format: int64 */ id: number; + /** + * @description Whether the user views the organization purely via public-project access + * @example false + */ + limitedView: boolean; /** @example Beautiful organization */ name: string; organizationModel?: components["schemas"]["OrganizationModel"]; diff --git a/webapp/src/views/organizations/OrganizationProfileView.tsx b/webapp/src/views/organizations/OrganizationProfileView.tsx index 5be54641d39..0601ba12385 100644 --- a/webapp/src/views/organizations/OrganizationProfileView.tsx +++ b/webapp/src/views/organizations/OrganizationProfileView.tsx @@ -19,6 +19,7 @@ import { OrganizationFields } from './components/OrganizationFields'; import { OrganizationProfileAvatar } from './OrganizationProfileAvatar'; import { useLeaveOrganization } from './useLeaveOrganization'; import { useIsAdmin } from 'tg.globalContext/helpers'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; type OrganizationBody = components['schemas']['OrganizationDto']; @@ -54,7 +55,7 @@ export const OrganizationProfileView: FunctionComponent< const isAdmin = useIsAdmin(); const readOnly = organization.data?.currentUserRole !== 'OWNER' && !isAdmin; - const notMember = !organization.data?.currentUserRole; + const hasNoRole = !isAtLeastMemberOrgRole(organization.data?.currentUserRole); const onSubmit = (values: OrganizationBody) => { const toSave = { @@ -150,7 +151,7 @@ export const OrganizationProfileView: FunctionComponent< color="secondary" variant="outlined" onClick={handleLeave} - disabled={notMember} + disabled={hasNoRole} > diff --git a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx index 521f80b7b50..3dd56396dd9 100644 --- a/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx +++ b/webapp/src/views/organizations/components/BaseOrganizationSettingsView.tsx @@ -16,6 +16,7 @@ import { usePreferredOrganization, } from 'tg.globalContext/helpers'; import { CriticalUsageCircle } from 'tg.ee'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; type OrganizationModel = components['schemas']['OrganizationModel']; @@ -54,6 +55,7 @@ export const BaseOrganizationSettingsView: React.FC< [PARAMS.ORGANIZATION_SLUG]: organizationSlug, }), label: t('organization_menu_profile'), + 'data-cy': 'profile', }, ]; @@ -77,12 +79,14 @@ export const BaseOrganizationSettingsView: React.FC< [PARAMS.ORGANIZATION_SLUG]: organizationSlug, }), label: t('organization_menu_glossaries'), + 'data-cy': 'glossaries', }); - // TM browse is gated server-side to actual org members — hide the link for project-only - // viewers so they don't land on a 403. Glossary stays visible for parity since it carries - // no virtual cross-project content. - if (preferredOrganization?.currentUserRole != null || isAdminOrSupporter) { + // hide the link; below-MEMBER users would hit a 403 on the TM endpoints + if ( + isAtLeastMemberOrgRole(preferredOrganization?.currentUserRole) || + isAdminOrSupporter + ) { menuItems.push({ link: LINKS.ORGANIZATION_TRANSLATION_MEMORIES.build({ [PARAMS.ORGANIZATION_SLUG]: organizationSlug, @@ -91,6 +95,7 @@ export const BaseOrganizationSettingsView: React.FC< 'organization_menu_translation_memories', 'Translation memories' ), + 'data-cy': 'translation-memories', }); } diff --git a/webapp/src/views/projects/ProjectListView.tsx b/webapp/src/views/projects/ProjectListView.tsx index 2ce81b8f0a2..54d4151ae48 100644 --- a/webapp/src/views/projects/ProjectListView.tsx +++ b/webapp/src/views/projects/ProjectListView.tsx @@ -19,12 +19,15 @@ import { OrganizationSwitch } from 'tg.component/organizationSwitch/Organization import { useLatchedSearchVisibility } from 'tg.views/projects/useLatchedSearchVisibility'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; import { CriticalUsageCircle } from 'tg.ee'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; export const ProjectListView = () => { const [page, setPage] = useState(0); const [search, setSearch] = useState(''); const { preferredOrganization } = usePreferredOrganization(); + const limitedView = Boolean(preferredOrganization?.limitedView); + const listPermitted = useApiQuery({ url: '/v2/organizations/{slug}/projects-with-stats', method: 'get', @@ -48,7 +51,8 @@ export const ProjectListView = () => { const isAdminOrSupporter = useIsAdminOrSupporter(); const isAdminAccess = - !preferredOrganization?.currentUserRole && isAdminOrSupporter; + !isAtLeastMemberOrgRole(preferredOrganization?.currentUserRole) && + isAdminOrSupporter; const addAllowed = isOrganizationOwnerOrMaintainer || isAdminAccess; @@ -92,6 +96,7 @@ export const ProjectListView = () => { > > = ({ messages.success(); } - const isOrganzationMember = Boolean(user.organizationRole); + const isOrganizationMember = isAtLeastMemberOrgRole(user.organizationRole); const hasDirectPermissions = Boolean(user.directPermission); async function handleResetToOrganization() { @@ -136,9 +137,9 @@ export const MemberItem: React.FC> = ({ permissions: user.computedPermission, onSubmit: handleSubmit, isInheritedFromOrganization: - !hasDirectPermissions && isOrganzationMember, + !hasDirectPermissions && isOrganizationMember, onResetToOrganization: - hasDirectPermissions && isOrganzationMember + hasDirectPermissions && isOrganizationMember ? handleResetToOrganization : undefined, }} diff --git a/webapp/src/views/projects/members/component/RevokePermissionsButton.tsx b/webapp/src/views/projects/members/component/RevokePermissionsButton.tsx index 145222d4e84..4cccb4ace42 100644 --- a/webapp/src/views/projects/members/component/RevokePermissionsButton.tsx +++ b/webapp/src/views/projects/members/component/RevokePermissionsButton.tsx @@ -11,11 +11,12 @@ import { useApiMutation } from 'tg.service/http/useQueryApi'; import { useLeaveProject } from 'tg.views/projects/useLeaveProject'; import { useProjectPermissions } from 'tg.hooks/useProjectPermissions'; import { messageService } from 'tg.service/MessageService'; +import { isAtLeastMemberOrgRole } from 'tg.fixtures/organizationRole'; const RevokePermissionsButton = (props: { user: components['schemas']['UserAccountInProjectModel']; }) => { - const hasOrganizationRole = !!props.user.organizationRole; + const isAtLeastMember = isAtLeastMemberOrgRole(props.user.organizationRole); const project = useProject(); const currentUser = useUser(); const { satisfiesPermission } = useProjectPermissions(); @@ -65,7 +66,7 @@ const RevokePermissionsButton = (props: { let isDisabled = false; let tooltip = undefined as ReactElement | undefined; - if (hasOrganizationRole) { + if (isAtLeastMember) { tooltip = ; isDisabled = true; } else if (currentUser!.id === props.user.id) {