Skip to content

Commit 4c1851e

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 4c1851e

6 files changed

Lines changed: 313 additions & 14 deletions

File tree

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

src/integration/spring_boot_test.go

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
. "github.com/onsi/gomega"
1212
)
1313

14-
func testSpringBoot(platform switchblade.Platform, fixtures string) func(*testing.T, spec.G, spec.S) {
14+
func testSpringBoot(platform switchblade.Platform, fixtures string, sb3JarPath, sb4JarPath string) func(*testing.T, spec.G, spec.S) {
1515
return func(t *testing.T, context spec.G, it spec.S) {
1616
var (
1717
Expect = NewWithT(t).Expect
@@ -290,5 +290,112 @@ func testSpringBoot(platform switchblade.Platform, fixtures string) func(*testin
290290
)).WithEndpoint("/jvm-args"))
291291
})
292292
})
293+
294+
// Tests using real Spring Boot fat jars from cloudfoundry/java-test-applications@v1.0.0.
295+
// SB4 (java-main-application) exposes RuntimeUtils endpoints (via core module):
296+
// GET /active-profiles -> ["cloud"] when java-cfenv activates cloud profile
297+
// GET /loaded-jars -> full classloader chain URLs incl. BOOT-INF/lib/*
298+
// GET /spring-env?key=<prop> -> Spring Environment property value
299+
// GET /environment-variables -> raw env vars
300+
// GET /input-arguments -> JVM input arguments (-javaagent etc.)
301+
// SB3 (java-main-application-boot3) only exposes GET / (no core module dependency).
302+
// Cloud profile activation verified via runtime logs for SB3.
303+
context("with real Spring Boot fat jars: java-cfenv injection", func() {
304+
it("SB3 (Spring Boot 3.x) -- java-cfenv 3.x, cloud profile via logs", func() {
305+
if sb3JarPath == "" {
306+
t.Skip("SB3 jar not available")
307+
}
308+
deployment, logs, err := platform.Deploy.
309+
WithServices(map[string]switchblade.Service{
310+
"db": {"uri": "postgres://host:5432/dbname"},
311+
}).
312+
WithEnv(map[string]string{
313+
"BP_JAVA_VERSION": "17",
314+
"JBP_CONFIG_JAVA_CF_ENV": "{enabled: true}",
315+
}).
316+
Execute(name, sb3JarPath)
317+
Expect(err).NotTo(HaveOccurred(), logs.String)
318+
319+
// Buildpack detected and injected correct java-cfenv 3.x
320+
Expect(logs.String()).To(ContainSubstring("Java CF Env"))
321+
Expect(logs.String()).To(ContainSubstring("3."))
322+
323+
// java-cfenv activated "cloud" profile — verified via Spring Boot startup log
324+
// (SB3 jar doesn't expose /active-profiles endpoint yet)
325+
// Use Eventually: RuntimeLogs() may be called before Spring Boot finishes starting.
326+
Eventually(func() (string, error) {
327+
return deployment.RuntimeLogs()
328+
}).Should(ContainSubstring(`profile is active: "cloud"`))
329+
330+
// App is live
331+
Eventually(deployment).Should(matchers.Serve(ContainSubstring("ok")).WithEndpoint("/"))
332+
})
333+
334+
it("SB4 (Spring Boot 4.x) -- java-cfenv 4.x, cloud profile, loaded-jars, vcap mapping", func() {
335+
if sb4JarPath == "" {
336+
t.Skip("SB4 jar not available")
337+
}
338+
deployment, logs, err := platform.Deploy.
339+
WithServices(map[string]switchblade.Service{
340+
"db": {"uri": "postgres://host:5432/dbname"},
341+
}).
342+
WithEnv(map[string]string{
343+
"BP_JAVA_VERSION": "21",
344+
"JBP_CONFIG_JAVA_CF_ENV": "{enabled: true}",
345+
}).
346+
Execute(name, sb4JarPath)
347+
Expect(err).NotTo(HaveOccurred(), logs.String)
348+
349+
// Buildpack detected and injected correct java-cfenv 4.x
350+
Expect(logs.String()).To(ContainSubstring("Java CF Env"))
351+
Expect(logs.String()).To(ContainSubstring("4."))
352+
353+
// java-cfenv activated "cloud" profile — direct endpoint assertion
354+
Eventually(deployment).Should(matchers.Serve(
355+
ContainSubstring("cloud")).WithEndpoint("/active-profiles"))
356+
357+
// java-cfenv jar loaded by JarLauncher via BOOT-INF/lib symlink — visible in /loaded-jars
358+
// (unlike /class-path which only shows JVM system classpath)
359+
Eventually(deployment).Should(matchers.Serve(
360+
ContainSubstring("java-cfenv")).WithEndpoint("/loaded-jars"))
361+
})
362+
363+
it("SB4 (Spring Boot 4.x) -- java-cfenv 4.x, container-security-provider, cf-metrics-exporter", func() {
364+
if sb4JarPath == "" {
365+
t.Skip("SB4 jar not available")
366+
}
367+
deployment, logs, err := platform.Deploy.
368+
WithServices(map[string]switchblade.Service{
369+
"db": {"uri": "postgres://host:5432/dbname"},
370+
}).
371+
WithEnv(map[string]string{
372+
"BP_JAVA_VERSION": "21",
373+
"JBP_CONFIG_JAVA_CF_ENV": "{enabled: true}",
374+
// cf-metrics-exporter: rpsType=random + enableLogEmitter requires no external infra
375+
"CF_METRICS_EXPORTER_ENABLED": "true",
376+
"CF_METRICS_EXPORTER_PROPS": "rpsType=random,enableLogEmitter",
377+
}).
378+
Execute(name, sb4JarPath)
379+
Expect(err).NotTo(HaveOccurred(), logs.String)
380+
381+
// java-cfenv: correct 4.x version detected and injected
382+
Expect(logs.String()).To(ContainSubstring("Java CF Env"))
383+
Expect(logs.String()).To(ContainSubstring("4."))
384+
385+
// cf-metrics-exporter: -javaagent present in JVM input arguments
386+
Expect(logs.String()).To(ContainSubstring("CF Metrics Exporter"))
387+
Expect(logs.String()).To(ContainSubstring("enabled, with properties: rpsType=random,enableLogEmitter"))
388+
Eventually(deployment).Should(matchers.Serve(
389+
ContainSubstring("cf-metrics-exporter")).WithEndpoint("/input-arguments"))
390+
391+
// container-security-provider: env var set at runtime
392+
Eventually(deployment).Should(matchers.Serve(
393+
ContainSubstring("CONTAINER_SECURITY_PROVIDER")).WithEndpoint("/environment-variables"))
394+
395+
// java-cfenv activated "cloud" profile
396+
Eventually(deployment).Should(matchers.Serve(
397+
ContainSubstring("cloud")).WithEndpoint("/active-profiles"))
398+
})
399+
})
293400
}
294-
}
401+
}

src/java/containers/spring_boot.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,13 @@ func (s *SpringBootContainer) findSpringBootJar(buildDir string) (string, error)
8686
return "", nil
8787
}
8888

89-
// isSpringBootJar checks if a JAR is a Spring Boot JAR
89+
// isSpringBootJar checks if a JAR is a Spring Boot JAR by filename heuristic.
90+
// In real CF staging, the artifact is extracted as a zip before the buildpack runs,
91+
// so BOOT-INF/ exists on disk and Detect() never reaches this path.
92+
// This fallback only matters when a fat jar is pushed without prior extraction
93+
// (e.g. local testing). For a more robust check, read MANIFEST.MF from inside
94+
// the jar zip and look for Spring-Boot-Version or Start-Class headers.
9095
func (s *SpringBootContainer) isSpringBootJar(jarPath string) bool {
91-
// TODO: In full implementation, we'd extract and check MANIFEST.MF
92-
// For now, check file name patterns
9396
name := filepath.Base(jarPath)
9497
return strings.Contains(name, "spring") ||
9598
strings.Contains(name, "boot") ||

0 commit comments

Comments
 (0)