Skip to content

Commit 7b93092

Browse files
committed
feat(integration): add real Spring Boot 3.x/4.x integration tests (#1357)
Add integration tests using real fat jars from cloudfoundry/java-test-applications v1.0.0 to verify end-to-end framework injection. Three tests: - SB3 (Boot 3.5, Java 17): java-cfenv 3.x injection, cloud profile verified via RuntimeLogs (boot3 app lacks RuntimeUtils endpoints) - SB4 (Boot 4.1, Java 21): java-cfenv 4.x injection, cloud profile via /active-profiles, java-cfenv presence via /loaded-jars - SB4 combined: java-cfenv + container-security-provider env var + cf-metrics-exporter javaagent — all in one deployment Key design decisions: - Pre-explode fat jars in fixtures_helper_test.go for Docker-mode tests to simulate CF staging (CF bits service extracts pushed jars as zip before running the buildpack). CF-mode tests pass raw jar to cf push. See switchblade#134 for CF-vs-Docker divergence. - Jars sourced from cloudfoundry/java-test-applications@v1.0.0. Also: - Fix dead 404 URL in frameworks_test.go - Update isSpringBootJar comment in spring_boot.go - Update README with correct 5-level -run filter patterns
1 parent 2be5f56 commit 7b93092

7 files changed

Lines changed: 373 additions & 14 deletions

File tree

docs/framework-java-cfenv.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@ The framework is implemented in `src/java/frameworks/java_cf_env.go`:
3434
3. **Finalize** — appends the installed jar to `CLASSPATH` via a `.profile.d/java_cf_env.sh` script, so it is on the application's runtime classpath.
3535
4. **Runtime** — Spring Boot reads the jar's `META-INF/spring.factories`: the `EnvironmentPostProcessor`s map `VCAP_SERVICES` to Spring properties, and `CloudProfileApplicationListener` (in the `java-cfenv-all` module) activates the `cloud` profile when running in Cloud Foundry.
3636

37+
## VCAP_SERVICES auto-configuration
38+
39+
Spring Boot has built-in support for flattening `VCAP_SERVICES` into `vcap.services.<name>.credentials.*` properties (`CloudFoundryVcapEnvironmentPostProcessor`). This works without java-cfenv but does **not** set `spring.datasource.url` or activate the `cloud` profile.
40+
41+
`java-cfenv-all` adds service-aware auto-configuration on top: it detects bound services by tag, label, or URI scheme and maps them to the appropriate Spring Boot properties. For example, a bound PostgreSQL service with URI `postgres://host:5432/db` is automatically mapped to `spring.datasource.url=jdbc:postgresql://host:5432/db` (plus username, password, and driver class). For reactive apps, `spring.r2dbc.*` properties are set as well. With a single bound database service, no `application.yml` DataSource configuration is needed.
42+
43+
This behavior is identical across java-cfenv 3.x and 4.x (the only difference is a Spring Boot 4 package relocation of `EnvironmentPostProcessor`).
44+
45+
### JDBC services (`CfDataSourceEnvironmentPostProcessor`)
46+
47+
| Service | Matching criteria | JDBC prefix |
48+
|---------|-------------------|-------------|
49+
| PostgreSQL | tag `postgresql`/`postgres`, label prefix `postgresql`, URI scheme `postgres://`/`postgresql://` | `jdbc:postgresql://` |
50+
| MySQL / MariaDB | tag `mysql`/`mariadb`, label prefix `mysql`/`mariadb`, URI scheme `mysql://`/`mariadb://` | `jdbc:mysql://` or `jdbc:mariadb://` |
51+
| SQL Server | label prefix `sqlserver`, URI scheme `sqlserver://` | `jdbc:sqlserver://` |
52+
| Oracle | label prefix `oracle`, URI scheme `oracle://` | `jdbc:oracle:thin:@` |
53+
| DB2 | tag `db2`/`sqldb`/`dashDB`, label prefix `db2`, URI scheme `db2://` | `jdbc:db2://` |
54+
55+
### Other services (`CfEnvProcessor` implementations)
56+
57+
| Service | Matching criteria | Properties set |
58+
|---------|-------------------|----------------|
59+
| MongoDB | tag `mongodb`, label prefix `mongolab`/`mongodb` | `spring.data.mongodb.uri` |
60+
| Redis | tag `redis`, URI scheme `redis://`/`rediss://` | `spring.data.redis.*` |
61+
| RabbitMQ | tag `rabbitmq`/`amqp`, URI scheme `amqp://`/`amqps://` | `spring.rabbitmq.*` |
62+
3763
## Configuration
3864

3965
The framework can be disabled via the `JBP_CONFIG_JAVA_CF_ENV` environment variable:

src/integration/README.md

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,41 @@ You can also run the tests directly using Go:
5757
cd src/integration
5858

5959
# Run all tests
60-
BUILDPACK_FILE=/path/to/buildpack.zip go test -v -timeout 30m
60+
BUILDPACK_FILE=/path/to/buildpack.zip go test -mod vendor -v -timeout 30m ./
6161

62-
# Run specific test suite
63-
BUILDPACK_FILE=/path/to/buildpack.zip go test -v -run TestIntegration/Tomcat
64-
65-
# Run on Docker
66-
BUILDPACK_FILE=/path/to/buildpack.zip go test -v -platform=docker
62+
# Run on Docker (requires GitHub token)
63+
BUILDPACK_FILE=/path/to/buildpack.zip go test -mod vendor -v -timeout 30m ./ \
64+
-platform=docker -github-token=<token>
6765

6866
# Run offline tests
69-
BUILDPACK_FILE=/path/to/buildpack.zip go test -v -cached
67+
BUILDPACK_FILE=/path/to/buildpack.zip go test -mod vendor -v -timeout 30m ./ -cached
68+
```
69+
70+
### Running a Subset of Tests
71+
72+
The spec framework creates subtests 5 levels deep:
73+
`TestIntegration/integration/<Suite>/<context>/<it>`
74+
75+
Use `-run` with a regex; Go does substring matching at each `/`-separated level:
76+
77+
```bash
78+
# All SpringBoot real-jar tests
79+
-run "TestIntegration/integration/SpringBoot/with_real_Spring_Boot_fat_jars"
80+
81+
# Single test — SB3 only
82+
-run "TestIntegration/integration/SpringBoot/with_real_Spring_Boot_fat_jars/SB3"
83+
84+
# Single test — SB4 cfenv + loaded-jars
85+
-run "TestIntegration/integration/SpringBoot/with_real_Spring_Boot_fat_jars/SB4.*cloud_profile"
86+
87+
# Single test — SB4 combined (cfenv + csp + metrics)
88+
-run "TestIntegration/integration/SpringBoot/with_real_Spring_Boot_fat_jars/SB4.*container-security"
89+
90+
# All Tomcat tests
91+
-run "TestIntegration/integration/Tomcat"
92+
93+
# All Frameworks tests
94+
-run "TestIntegration/integration/Frameworks"
7095
```
7196

7297
## Test Organization
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package integration_test
2+
3+
import (
4+
"archive/zip"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
)
13+
14+
const (
15+
javaTestAppsReleaseTag = "v1.0.0"
16+
javaTestAppsBaseURL = "https://github.com/cloudfoundry/java-test-applications/releases/download/" + javaTestAppsReleaseTag
17+
sb3JarName = "java-main-application-boot3-1.0.0.jar"
18+
sb4JarName = "java-main-application-1.0.0.jar"
19+
)
20+
21+
// downloadJavaTestAppsJars downloads the pinned SB3 and SB4 fat jars.
22+
// For Docker-mode tests, jars are extracted (exploded) into fixture directories
23+
// to simulate what real CF staging does: CF treats the pushed artifact as a zip
24+
// and extracts it before running the buildpack, so BOOT-INF/ and META-INF/
25+
// appear as flat files on disk.
26+
// For CF-mode tests, the raw jar is returned as-is — `cf push -p` handles
27+
// zip extraction natively.
28+
//
29+
// Pre-exploding is the correct pattern for Docker-mode tests: switchblade's
30+
// Docker mode archives the fixture directory as-is (TGZArchiver), while CF mode
31+
// delegates to `cf push -p` which handles zip extraction natively.
32+
// See https://github.com/cloudfoundry/switchblade/issues/134 for details.
33+
//
34+
// Returns (sb3FixturePath, sb4FixturePath, cleanup func, error).
35+
func downloadJavaTestAppsJars(platform string) (string, string, func(), error) {
36+
dir, err := os.MkdirTemp("", "java-test-apps-*")
37+
if err != nil {
38+
return "", "", nil, fmt.Errorf("create temp dir: %w", err)
39+
}
40+
41+
cleanup := func() { os.RemoveAll(dir) }
42+
43+
type jarEntry struct {
44+
jarName string
45+
path string // set after download/extract
46+
}
47+
entries := []jarEntry{
48+
{jarName: sb3JarName},
49+
{jarName: sb4JarName},
50+
}
51+
52+
for i := range entries {
53+
jarPath := filepath.Join(dir, entries[i].jarName)
54+
if err := downloadFile(javaTestAppsBaseURL+"/"+entries[i].jarName, jarPath); err != nil {
55+
cleanup()
56+
return "", "", nil, fmt.Errorf("download %s: %w", entries[i].jarName, err)
57+
}
58+
59+
if platform == "docker" {
60+
// Docker mode: explode jar into directory (simulates CF staging zip extraction).
61+
// Switchblade Docker archives the fixture dir as-is via TGZArchiver.
62+
explodedDir := filepath.Join(dir, fmt.Sprintf("exploded-%d", i))
63+
if err := extractZip(jarPath, explodedDir); err != nil {
64+
cleanup()
65+
return "", "", nil, fmt.Errorf("extract %s: %w", entries[i].jarName, err)
66+
}
67+
_ = os.Remove(jarPath)
68+
entries[i].path = explodedDir
69+
} else {
70+
// CF mode: return raw jar path — `cf push -p` handles zip extraction natively.
71+
entries[i].path = jarPath
72+
}
73+
}
74+
75+
return entries[0].path, entries[1].path, cleanup, nil
76+
}
77+
78+
// extractZip extracts a zip/jar file into destDir, replicating the flat-file
79+
// layout that CF staging creates when it unpacks the pushed artifact.
80+
func extractZip(src, destDir string) error {
81+
r, err := zip.OpenReader(src)
82+
if err != nil {
83+
return fmt.Errorf("open zip: %w", err)
84+
}
85+
defer r.Close()
86+
87+
for _, f := range r.File {
88+
target := filepath.Join(destDir, f.Name)
89+
90+
// Guard against zip-slip
91+
if !strings.HasPrefix(filepath.Clean(target)+string(os.PathSeparator),
92+
filepath.Clean(destDir)+string(os.PathSeparator)) {
93+
return fmt.Errorf("zip entry %q escapes destination", f.Name)
94+
}
95+
96+
if f.FileInfo().IsDir() {
97+
if err := os.MkdirAll(target, 0755); err != nil {
98+
return err
99+
}
100+
continue
101+
}
102+
103+
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
104+
return err
105+
}
106+
107+
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode())
108+
if err != nil {
109+
return err
110+
}
111+
112+
rc, err := f.Open()
113+
if err != nil {
114+
out.Close()
115+
return err
116+
}
117+
118+
_, copyErr := io.Copy(out, rc)
119+
rc.Close()
120+
out.Close()
121+
if copyErr != nil {
122+
return copyErr
123+
}
124+
}
125+
return nil
126+
}
127+
128+
func downloadFile(url, dest string) error {
129+
client := &http.Client{Timeout: 2 * time.Minute}
130+
resp, err := client.Get(url) //nolint:noctx
131+
if err != nil {
132+
return err
133+
}
134+
defer resp.Body.Close()
135+
136+
if resp.StatusCode != http.StatusOK {
137+
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
138+
}
139+
140+
f, err := os.Create(dest)
141+
if err != nil {
142+
return err
143+
}
144+
defer f.Close()
145+
146+
_, err = io.Copy(f, resp.Body)
147+
return err
148+
}

src/integration/frameworks_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ func testFrameworks(platform switchblade.Platform, fixtures string) func(*testin
445445
deployment, logs, err := platform.Deploy.
446446
WithServices(map[string]switchblade.Service{
447447
"checkmarx-iast": {
448-
"url": "https://github.com/cloudfoundry/java-test-applications/raw/main/java-main-application/java-main-application.jar",
448+
"url": "https://github.com/cloudfoundry/java-test-applications/releases/download/v1.0.0/java-main-application-1.0.0.jar",
449449
"manager_url": "https://checkmarx.example.com",
450450
"api_key": "test-api-key-12345",
451451
},

src/integration/init_test.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ var settings struct {
2828
GitHubToken string
2929
Platform string
3030
Stack string
31+
32+
// Paths to fixture directories containing real Spring Boot fat jars from java-test-applications release.
33+
// Each directory holds a single fat jar; passed to switchblade Deploy.Execute as the app source.
34+
// Populated in TestIntegration before the suite runs.
35+
SB3JarPath string
36+
SB4JarPath string
3137
}
3238

3339
func init() {
@@ -66,6 +72,16 @@ func TestIntegration(t *testing.T) {
6672
)
6773
Expect(err).NotTo(HaveOccurred())
6874

75+
// Download real Spring Boot fat jars for java-cfenv integration tests.
76+
// Skip in cached/offline mode — tests self-skip when paths are empty.
77+
if !settings.Cached {
78+
sb3Jar, sb4Jar, cleanupJars, err := downloadJavaTestAppsJars(settings.Platform)
79+
Expect(err).NotTo(HaveOccurred(), "failed to download java-test-applications jars for integration tests")
80+
defer cleanupJars()
81+
settings.SB3JarPath = sb3Jar
82+
settings.SB4JarPath = sb4Jar
83+
}
84+
6985
var suite spec.Suite
7086
if settings.Serial {
7187
suite = spec.New("integration", spec.Report(report.Terminal{}), spec.Sequential())
@@ -75,7 +91,7 @@ func TestIntegration(t *testing.T) {
7591

7692
// Core container tests
7793
suite("Tomcat", testTomcat(platform, fixtures))
78-
suite("SpringBoot", testSpringBoot(platform, fixtures))
94+
suite("SpringBoot", testSpringBoot(platform, fixtures, settings.SB3JarPath, settings.SB4JarPath))
7995
suite("JavaMain", testJavaMain(platform, fixtures))
8096
suite("DistZip", testDistZip(platform, fixtures))
8197

0 commit comments

Comments
 (0)