Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.tolgee.api.v2.controllers.project

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.api.v2.controllers.IController
import io.tolgee.facade.ProjectWithStatsFacade
import io.tolgee.hateoas.project.ProjectWithStatsModel
import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs
import io.tolgee.service.project.ProjectService
import org.springdoc.core.annotations.ParameterObject
import org.springframework.data.domain.Pageable
import org.springframework.data.web.SortDefault
import org.springframework.hateoas.PagedModel
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping(value = ["/v2/public/projects"])
@Tag(name = "Public projects")
@OpenApiHideFromPublicDocs
class PublicProjectsController(
private val projectService: ProjectService,
private val projectWithStatsFacade: ProjectWithStatsFacade,
) : IController {
@Operation(
summary = "Get all public projects with stats",
description =
"Returns all public projects (including statistics), discoverable by anyone — no authentication required",
)
@GetMapping("/with-stats")
@Transactional(readOnly = true)
fun getAllPublicWithStatistics(
@ParameterObject @SortDefault("name") pageable: Pageable,
@RequestParam("search") search: String?,
): PagedModel<ProjectWithStatsModel> {
val projects = projectService.findAllPublicPaged(pageable, search)
return projectWithStatsFacade.getPagedModelWithStats(projects)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ class WebSecurityConfig(
registry
.addInterceptor(organizationAuthorizationInterceptor)
.addPathPatterns(*ORGANIZATION_ENDPOINTS)
// These authorization interceptors are NOT registered for /v2/public/**; routes there must stay free
// of {projectId}/{organizationId} path vars + @RequiresProjectPermissions — adding one would silently
// bypass authorization.
registry
.addInterceptor(projectAuthorizationInterceptor)
.addPathPatterns(*PROJECT_ENDPOINTS)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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.fixtures.node
import io.tolgee.testing.AuthorizedControllerTest
import io.tolgee.testing.assert
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest

@SpringBootTest
@AutoConfigureMockMvc
class PublicProjectsControllerTest : AuthorizedControllerTest() {
private lateinit var testData: PublicProjectsControllerTestData

@BeforeEach
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()
}
}

@Test
fun `lists only public projects to an anonymous visitor`() {
performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects") {
isArray.hasSize(2)
node("[0].id").isEqualTo(testData.otherOrgPublicProject.id)
node("[1].id").isEqualTo(testData.publicProject.id)
}
}
}

@Test
fun `a public row carries stats, org and NONE permission for an anonymous visitor`() {
performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects[1]") {
node("id").isEqualTo(testData.publicProject.id)
node("public").isEqualTo(true)
node("organizationOwner.name").isEqualTo("test_username")
node("stats.keyCount").isEqualTo(0)
node("languages").isArray.hasSize(1)
node("directPermission").isEqualTo(null)
node("organizationRole").isEqualTo(null)
node("computedPermission.type").isEqualTo("NONE")
node("computedPermission.scopes").isArray.hasSize(0)
}
}
}

@Test
fun `search matches the project name case-insensitively`() {
performGet("/v2/public/projects/with-stats?search=other ORG").andIsOk.andAssertThatJson {
node("_embedded.projects") {
isArray.hasSize(1)
node("[0].id").isEqualTo(testData.otherOrgPublicProject.id)
}
}
}

@Test
fun `search matches the organization name case-insensitively`() {
performGet("/v2/public/projects/with-stats?search=vibrant").andIsOk.andAssertThatJson {
node("_embedded.projects") {
isArray.hasSize(1)
node("[0].id").isEqualTo(testData.otherOrgPublicProject.id)
}
}
}

@Test
fun `pages the public projects`() {
performGet("/v2/public/projects/with-stats?size=1").andIsOk.andAssertThatJson {
node("_embedded.projects").isArray.hasSize(1)
node("page.totalElements").isEqualTo(2)
node("page.size").isEqualTo(1)
}
}

@Test
fun `the with-stats payload reflects the public flag of each project`() {
userAccount = testData.user
performAuthGet("/v2/projects/with-stats?sort=id").andIsOk.andAssertThatJson {
node("_embedded.projects") {
node("[0].id").isEqualTo(testData.privateProject.id)
node("[0].public").isEqualTo(false)
node("[1].id").isEqualTo(testData.publicProject.id)
node("[1].public").isEqualTo(true)
}
}
}

@Test
fun `a logged-in member sees their real permission on a public row (per-user join wired)`() {
userAccount = testData.user
performAuthGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects") {
node("[0].id").isEqualTo(testData.otherOrgPublicProject.id)
node("[0].organizationRole").isEqualTo(null)
node("[0].computedPermission.type").isEqualTo("VIEW")
node("[0].computedPermission.origin").isEqualTo("COMMUNITY")
node("[1].id").isEqualTo(testData.publicProject.id)
node("[1].organizationRole").isEqualTo("OWNER")
node("[1].computedPermission.type").isEqualTo("MANAGE")
}
}
}

@Test
fun `a logged-in user sees their direct project permission on a public row`() {
userAccount = testData.directPermissionUser
performAuthGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects[0]") {
node("id").isEqualTo(testData.otherOrgPublicProject.id)
node("organizationRole").isEqualTo(null)
node("directPermission.type").isEqualTo("TRANSLATE")
node("computedPermission.type").isEqualTo("TRANSLATE")
}
}
}

@Test
fun `excludes a soft-deleted public project`() {
performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects").isArray.hasSize(2)
}
}

@Test
fun `a logged-in non-member gets the community permission on a public row`() {
userAccount = testData.nonMember
performAuthGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects") {
isArray.hasSize(2)
node("[1].id").isEqualTo(testData.publicProject.id)
node("[1].directPermission").isEqualTo(null)
node("[1].organizationRole").isEqualTo(null)
node("[1].computedPermission.type").isEqualTo("VIEW")
node("[1].computedPermission.origin").isEqualTo("COMMUNITY")
}
}
}

@Test
fun `excludes a public project with no base language without mutating it`() {
performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects").isArray.hasSize(2)
}
baseLanguageId(testData.noBaseLanguageProject.id).assert.isNull()
}

@Test
fun `excludes a public project whose base language is soft-deleted without mutating it`() {
val baseBefore = baseLanguageId(testData.softDeletedBaseProject.id)
performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects").isArray.hasSize(2)
}
baseLanguageId(testData.softDeletedBaseProject.id).assert.isEqualTo(baseBefore)
}

@Test
fun `excludes a public project with no organization owner`() {
performGet("/v2/public/projects/with-stats").andIsOk.andAssertThatJson {
node("_embedded.projects").isArray.hasSize(2)
}
}

private fun baseLanguageId(projectId: Long): Long? =
executeInNewTransaction {
(
entityManager
.createNativeQuery("select base_language_id from project where id = :id")
.setParameter("id", projectId)
.singleResult as Number?
)?.toLong()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package io.tolgee.development.testDataBuilder.data

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

class PublicProjectsControllerTestData : BaseTestData() {
val privateProject: Project get() = project

lateinit var publicProject: Project
lateinit var otherOrg: Organization
lateinit var otherOrgPublicProject: Project
lateinit var nonMember: UserAccount

lateinit var directPermissionUser: UserAccount

lateinit var noBaseLanguageProject: Project
lateinit var softDeletedBaseProject: Project
lateinit var orgLessProject: Project
lateinit var deletedPublicProject: Project

init {
root.apply {
nonMember =
addUserAccount {
username = "non_member"
name = "Non Member"
}.self

directPermissionUser =
addUserAccount {
username = "direct_perm_user"
name = "Direct Perm User"
}.self

addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) {
name = "Public project"
public = true
}.build {
publicProject = self
addBaseLanguage()
}

otherOrg =
addOrganization {
name = "Vibrant translators"
}.self

addProject(organizationOwner = otherOrg) {
name = "Other org public project"
public = true
}.build {
otherOrgPublicProject = self
addBaseLanguage()
addPermission {
user = this@PublicProjectsControllerTestData.directPermissionUser
type = ProjectPermissionType.TRANSLATE
}
}

addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) {
name = "No base language project"
public = true
}.build {
noBaseLanguageProject = self
addBaseLanguage()
}

addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) {
name = "Soft-deleted base project"
public = true
}.build {
softDeletedBaseProject = self
addBaseLanguage()
}

addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) {
name = "Org-less project"
public = true
}.build {
orgLessProject = self
addBaseLanguage()
}

addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) {
name = "Deleted public project"
public = true
}.build {
deletedPublicProject = self
addBaseLanguage()
}
}
}

private fun ProjectBuilder.addBaseLanguage() {
addLanguage {
name = "English"
tag = "en"
originalName = "English"
this@addBaseLanguage.self.baseLanguage = this
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.tolgee.development.testDataBuilder.data

import io.tolgee.development.testDataBuilder.builders.ProjectBuilder

/**
* E2E fixture for the /public-projects page: seeds `count` public projects plus the non-public
* BaseTestData project ("Private project") used for the exclusion assertion.
*/
class PublicProjectsE2eData(
count: Int = 6,
) : BaseTestData("publicProjectsUser", "Private project") {
init {
root.apply {
listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta").take(count).forEach { suffix ->
addProject(organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self) {
name = "Community $suffix"
public = true
}.build {
addBaseLanguage()
}
}
}
}

private fun ProjectBuilder.addBaseLanguage() {
addLanguage {
name = "English"
tag = "en"
originalName = "English"
this@addBaseLanguage.self.baseLanguage = this
}
}
}
Loading
Loading