Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8263084
test(harness): add full-context @SpringBootTest integration harness
krusche Jul 3, 2026
d20958d
test(scoping): guard that GET /api/environments* is scoped per reposi…
krusche Jul 3, 2026
16234a7
refactor(env): explicit per-repository filtering for environment reads
krusche Jul 3, 2026
1557a7c
refactor(branch): explicit per-repository filtering for GET /api/bran…
krusche Jul 3, 2026
e6ab21f
refactor(env,branch): never findAll for tenant reads — empty on missi…
krusche Jul 3, 2026
0738587
refactor(workflow): explicit per-repository filtering for workflow reads
krusche Jul 3, 2026
e11d967
refactor(deployment): explicit per-repository filtering for deploymen…
krusche Jul 3, 2026
b9233f8
refactor(workflow-run): remove dead unscoped getAllWorkflowRuns
krusche Jul 3, 2026
342ad2a
refactor(pull-request): explicit per-repository filtering for pull-re…
krusche Jul 3, 2026
9db96af
refactor(release-info): explicit per-repository filtering for getAllR…
krusche Jul 3, 2026
57341d6
refactor(tenancy): remove the Hibernate gitRepositoryFilter — scoping…
krusche Jul 3, 2026
fc3f484
fix(settings): stop GET /settings from persisting a settings row (wri…
krusche Jul 3, 2026
1dc0e04
refactor(tx): drop class-level @Transactional from GitHubService
krusche Jul 3, 2026
c42405b
refactor(tx): remove class-level @Transactional from read-heavy services
krusche Jul 3, 2026
5be962f
refactor(tx): remove class-level @Transactional from GitRepoSettingsS…
krusche Jul 3, 2026
b21463a
refactor: delete dead unscoped finders + empty IssueRepository
krusche Jul 4, 2026
a543030
refactor(tx): keep @Transactional only where mechanically required
krusche Jul 4, 2026
15dabfe
fix(tenancy): close cross-repo leaks surfaced by deep review
krusche Jul 4, 2026
c9de350
fix(settings): PUT /settings creates the row on absent
krusche Jul 4, 2026
ca5220e
refactor: remove dead PullRequestRepository.findAllByOrderByUpdatedAt…
krusche Jul 4, 2026
fb41e47
fix(tx): run read methods @Transactional(readOnly = true) — Postgres …
krusche Jul 4, 2026
9258efd
fix(tx): run TestResultService reads @Transactional(readOnly = true)
krusche Jul 4, 2026
db87b28
refactor(tx): disable JDBC auto-commit instead of annotating reads @T…
krusche Jul 4, 2026
677cd3e
test(tenancy): add PullRequestScopingIT (real-path cross-repo guard)
krusche Jul 4, 2026
628d45d
test(tenancy): add WorkflowRunScopingIT (real-path cross-repo guard)
krusche Jul 4, 2026
a667ee5
test(tenancy): add ReleaseInfoScopingIT (real-path cross-repo guard)
krusche Jul 4, 2026
2d8d290
test(tenancy): add TestResultScopingIT (real-path cross-repo guard)
krusche Jul 4, 2026
663d403
test(settings): add GitRepoSettingsIT (write-on-GET guard)
krusche Jul 4, 2026
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
Expand Up @@ -24,6 +24,13 @@ public interface EnvironmentRepository extends JpaRepository<Environment, Long>

List<Environment> findByEnabledTrueOrderByNameAsc();

// Explicit per-repository scoped finders (Option B: replaces the ambient gitRepositoryFilter).
List<Environment> findByRepositoryRepositoryIdOrderByNameAsc(Long repositoryId);

List<Environment> findByEnabledTrueAndRepositoryRepositoryIdOrderByNameAsc(Long repositoryId);

Optional<Environment> findByIdAndRepositoryRepositoryId(Long id, Long repositoryId);

@Query("SELECT DISTINCT e FROM Environment e "
+ "LEFT JOIN FETCH e.statusHistory es "
+ "WHERE (es is NULL OR es.checkTimestamp = "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,32 @@ public class EnvironmentService {
private final NatsNotificationPublisherService notificationPublisherService;

public Optional<EnvironmentDto> getEnvironmentById(Long id) {
return environmentRepository.findById(id).map(EnvironmentDto::fromEnvironment);
return findScopedById(id).map(EnvironmentDto::fromEnvironment);
}

public Optional<Environment.Type> getEnvironmentTypeById(Long id) {
return environmentRepository.findById(id).map(Environment::getType);
return findScopedById(id).map(Environment::getType);
}

/**
* Loads an environment by id, scoped to the current repository when a repository context is
* present. Explicit replacement for the ambient gitRepositoryFilter, which never applied to
* findById/PK loads — so this also closes a latent cross-repository read.
*/
private Optional<Environment> findScopedById(Long id) {
Long repositoryId = RepositoryContext.getRepositoryId();
return repositoryId == null
? environmentRepository.findById(id)
: environmentRepository.findByIdAndRepositoryRepositoryId(id, repositoryId);
}

public List<EnvironmentDto> getAllEnvironments() {
return environmentRepository.findAllByOrderByNameAsc().stream()
Long repositoryId = RepositoryContext.getRepositoryId();
List<Environment> environments =
repositoryId == null
? environmentRepository.findAllByOrderByNameAsc()
: environmentRepository.findByRepositoryRepositoryIdOrderByNameAsc(repositoryId);
return environments.stream()
.map(
environment -> {
LatestDeploymentUnion latest = findLatestDeployment(environment);
Expand All @@ -98,7 +115,13 @@ public List<EnvironmentDto> getAllEnvironments() {
}

public List<EnvironmentDto> getAllEnabledEnvironments() {
return environmentRepository.findByEnabledTrueOrderByNameAsc().stream()
Long repositoryId = RepositoryContext.getRepositoryId();
List<Environment> environments =
repositoryId == null
? environmentRepository.findByEnabledTrueOrderByNameAsc()
: environmentRepository.findByEnabledTrueAndRepositoryRepositoryIdOrderByNameAsc(
repositoryId);
return environments.stream()
.map(
environment -> {
LatestDeploymentUnion latest = findLatestDeployment(environment);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,53 @@
# Profile used by full-context integration tests (HeliosIntegrationTest). Supplies dummy values for
# every no-default @Value placeholder and disables all external integrations / schedulers so the
# context boots without credentials, network, NATS, or a running scheduler. Not used in any real
# deployment (prod/staging/dev activate their own profiles).
spring:
data:
jpa:
repositories:
bootstrap-mode: deferred
# deferred needs an async bootstrap executor not wired in the test context
bootstrap-mode: default
security:
oauth2:
resourceserver:
jwt:
issuer-uri: "url.invalid"
issuer-uri: "url.invalid"
flyway:
enabled: true

nats:
enabled: false
server: "nats://localhost:4222"
auth:
token: "dummy"
durableConsumerName: "test-consumer"
consumerAckWaitSeconds: 300
consumerInactiveThresholdMinutes: 45
timeframe: 1440

github:
organizationName: "test-org"
authToken: "dummy-token"
appName: "test-app"
clientId: "dummy-client"
privateKeyPath: "dummy-key.pem"
tokenExchangeClientId: "dummy-exchange-client"
tokenExchangeClientSecret: "dummy-exchange-secret"

monitoring:
repositories: ""
timeframe: 14
repository-sync-cron: "0 0 4 * * *"
runOnStartup: false
runOnStartupCooldownInMinutes: 1440

notification:
enabled: false

reconciliation:
enabled: false

helios:
ai:
enabled: false
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package de.tum.cit.aet.helios;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;

/** Smoke test: the full application context boots and the servlet stack answers a public GET. */
class ContextBootIT extends HeliosIntegrationTest {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.naming.AbbreviationAsWordInNameCheck> reported by reviewdog 🐶
Abbreviation in name 'ContextBootIT' must contain no more than '1' consecutive capital letters.


@Test
void contextLoadsAndApiResponds() throws Exception {
mockMvc.perform(get("/api/repository")).andExpect(status().isOk());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package de.tum.cit.aet.helios;

import de.tum.cit.aet.helios.github.GitHubClientManager;
import de.tum.cit.aet.helios.github.GitHubFacade;
import de.tum.cit.aet.helios.github.GitHubService;
import io.zonky.test.db.AutoConfigureEmbeddedDatabase;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

/**
* Base class for full-context integration tests. Boots the whole application against a real embedded

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.sizes.LineLengthCheck> reported by reviewdog 🐶
Line is longer than 100 characters (found 101).

* PostgreSQL (zonky, via Docker) with the real Flyway schema, and exposes {@link MockMvc} so tests
* drive the actual servlet stack — interceptors, Open-Session-In-View, and the repository layer.
*
* <p>This is the harness for the tenant-isolation guard tests: it exercises the same request path that

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.sizes.LineLengthCheck> reported by reviewdog 🐶
Line is longer than 100 characters (found 103).

* production uses, so a scoping test written here passes both with the legacy Hibernate
* {@code gitRepositoryFilter} and with explicit per-query filtering after the migration.
*
* <p>External integrations that would otherwise reach out on startup are neutralised: NATS is disabled

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.sizes.LineLengthCheck> reported by reviewdog 🐶
Line is longer than 100 characters (found 103).

* ({@code nats.enabled=false}), the schedulers/reconciliation/notifications/AI are turned off, and the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.sizes.LineLengthCheck> reported by reviewdog 🐶
Line is longer than 100 characters (found 103).

* GitHub clients are mocked so no credentials or network are required.
*/
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
@AutoConfigureEmbeddedDatabase(
type = AutoConfigureEmbeddedDatabase.DatabaseType.POSTGRES,
provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.DOCKER)
public abstract class HeliosIntegrationTest {

public static final String X_REPOSITORY_ID = "X-REPOSITORY-ID";

@Autowired protected MockMvc mockMvc;
@Autowired protected DataSource dataSource;

// GitHub clients reach out to GitHub on construction/first use; mock them so the context boots
// without credentials or network. Read (GET) scoping tests do not exercise GitHub.
@MockitoBean protected GitHubClientManager gitHubClientManager;
@MockitoBean protected GitHubFacade gitHubFacade;
@MockitoBean protected GitHubService gitHubService;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package de.tum.cit.aet.helios.environment;

import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.everyItem;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import de.tum.cit.aet.helios.HeliosIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;

/**
* Cross-refactor guard: {@code GET /api/environments*} must return only the current repository's
* environments (scoped by the {@code X-REPOSITORY-ID} header). This passes with the legacy
* Hibernate {@code gitRepositoryFilter} and must keep passing after the switch to explicit
* per-query filtering — it exercises the real request path (interceptor → OSIV → repository).
*/
class EnvironmentScopingIT extends HeliosIntegrationTest {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [checkstyle] <com.puppycrawl.tools.checkstyle.checks.naming.AbbreviationAsWordInNameCheck> reported by reviewdog 🐶
Abbreviation in name 'EnvironmentScopingIT' must contain no more than '1' consecutive capital letters.


private static final long REPO_A = 1L;
private static final long REPO_B = 2L;
private static final long ENV_A1 = 11L;
private static final long ENV_A2 = 12L;
private static final long ENV_B1 = 21L;

@BeforeEach
void seed() {
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
jdbc.execute("TRUNCATE TABLE repository CASCADE");
insertRepo(jdbc, REPO_A, "ls1intum/repo-a");
insertRepo(jdbc, REPO_B, "ls1intum/repo-b");
insertEnabledEnv(jdbc, ENV_A1, REPO_A, "a-staging");
insertEnabledEnv(jdbc, ENV_A2, REPO_A, "a-production");
insertEnabledEnv(jdbc, ENV_B1, REPO_B, "b-production");
}

@Test
void enabledEnvironmentsAreScopedToRepositoryA() throws Exception {
mockMvc
.perform(get("/api/environments/enabled").header(X_REPOSITORY_ID, String.valueOf(REPO_A)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[*].repository.id", everyItem(equalTo((int) REPO_A))));
}

@Test
void enabledEnvironmentsAreScopedToRepositoryB() throws Exception {
mockMvc
.perform(get("/api/environments/enabled").header(X_REPOSITORY_ID, String.valueOf(REPO_B)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].repository.id").value((int) REPO_B));
}

@Test
void allEnvironmentsAreScopedToCurrentRepository() throws Exception {
mockMvc
.perform(get("/api/environments").header(X_REPOSITORY_ID, String.valueOf(REPO_A)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[*].repository.id", everyItem(equalTo((int) REPO_A))));
}

@Test
void environmentByIdIsInvisibleFromAnotherRepository() throws Exception {
// ENV_B1 belongs to repo B: invisible when scoped to repo A, visible when scoped to repo B.
mockMvc
.perform(
get("/api/environments/{id}", ENV_B1).header(X_REPOSITORY_ID, String.valueOf(REPO_A)))
.andExpect(status().isNotFound());
mockMvc
.perform(
get("/api/environments/{id}", ENV_B1).header(X_REPOSITORY_ID, String.valueOf(REPO_B)))
.andExpect(status().isOk());
}

private static void insertRepo(JdbcTemplate jdbc, long id, String nameWithOwner) {
jdbc.update(
"INSERT INTO repository (repository_id, has_issues, has_projects, has_wiki, is_archived, "
+ "is_disabled, is_private, stargazers_count, watchers_count, name_with_owner) "
+ "VALUES (?, false, false, false, false, false, false, 0, 0, ?)",
id,
nameWithOwner);
}

private static void insertEnabledEnv(JdbcTemplate jdbc, long id, long repositoryId, String name) {
jdbc.update(
"INSERT INTO environment (id, repository_id, enabled, locked, name) "
+ "VALUES (?, ?, true, false, ?)",
id,
repositoryId,
name);
}
}
Loading