Skip to content

Commit b769565

Browse files
add Go unit tests for retrieveToken and vcs URL rewriting
Covers the two pieces of novel backend logic added in the recent port batch (PR 5169 + PR 5195). auth_test.go: - TestRetrieveToken — success case. Mirrors TestVerifySession's mock plumbing (setupHTTPTest, sqlmock, InitStratosAuthService) to stub a signed-session user_id/exp and verify the handler returns an AuthTokenEnvelope containing the decrypted TokenRecord. - TestRetrieveTokenNoSessionDate — error path. Omits "exp" from the session so GetSessionInt64Value fails, and verifies the handler writes an error envelope (status:"error", data:null) rather than propagating the error to echo. vcs.go: - Extracted the access-token URL rewrite logic from Create() into vcsCmd.repoWithToken(repo). Create() now delegates to it. Pure refactor — no behavioural change — done so the rewrite can be unit-tested without shelling out to a real git binary. vcs_test.go (new file, six tests): - TestRepoWithToken_NoTokenReturnsURLUnchanged — no-op when token empty - TestRepoWithToken_EmbedsTokenAsBasicAuth — PAT injected as x-access-token basic-auth userinfo - TestRepoWithToken_InvalidURLReturnsError — malformed URL surfaces a wrapped parse error - TestRepoWithToken_SpecialCharactersInTokenEscaped — tokens containing @, :, / must be percent-encoded so they don't prematurely terminate the userinfo section. This is the class of bug that would silently point git at the wrong host. - TestGetVCS_ReturnsFreshCopyNotPrototype — confirms GetVCS() returns a copy of the package-level vcsGit prototype rather than the prototype itself, so concurrent callers can't race on a mutated accessToken field. Directly exercises the concurrency fix that deviated from upstream PR #5195's GetVCS implementation. - TestGetVCS_WithAccessTokenOption — confirms the functional-options pattern actually applies the token to the returned struct and preserves the prototype's other fields.
1 parent 3b88706 commit b769565

3 files changed

Lines changed: 234 additions & 8 deletions

File tree

src/jetstream/auth_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,3 +1136,107 @@ func TestVerifySessionExpired(t *testing.T) {
11361136
})
11371137

11381138
}
1139+
1140+
func TestRetrieveToken(t *testing.T) {
1141+
t.Parallel()
1142+
1143+
Convey("Test retrieve UAA token for current session", t, func() {
1144+
1145+
req := setupMockReq("GET", "", map[string]string{
1146+
"username": "admin",
1147+
"password": "changeme",
1148+
})
1149+
res, _, ctx, pp, db, mock := setupHTTPTest(req)
1150+
defer db.Close()
1151+
1152+
if e := pp.InitStratosAuthService(api.Remote); e != nil {
1153+
log.Fatalf("Could not initialise auth service: %v", e)
1154+
}
1155+
1156+
// Mirror TestVerifySession: stub a signed-session user_id and
1157+
// exp so the handler's session lookups succeed without a real
1158+
// login round-trip.
1159+
sessionValues := make(map[string]interface{})
1160+
sessionValues["user_id"] = mockUserGUID
1161+
sessionValues["exp"] = time.Now().Add(time.Hour).Unix()
1162+
1163+
if errSession := pp.setSessionValues(ctx, sessionValues); errSession != nil {
1164+
t.Error(errors.New("unable to mock/stub user in session object"))
1165+
}
1166+
1167+
mockTokenGUID := "mock-token-guid"
1168+
encryptedUAAToken, _ := crypto.EncryptToken(pp.Config.EncryptionKeyInBytes, mockUAAToken)
1169+
1170+
// The handler runs VerifySession before the token lookup;
1171+
// VerifySession reads from the tokens table too.
1172+
expectedTokensRow := testutils.GetEmptyTokenRows("disconnected", "user_guid", "linked_token").
1173+
AddRow(mockTokenGUID, encryptedUAAToken, encryptedUAAToken, mockTokenExpiry, "oauth", "", true)
1174+
mock.ExpectQuery(selectAnyFromTokens).
1175+
WithArgs(mockUserGUID).
1176+
WillReturnRows(expectedTokensRow)
1177+
1178+
// GetUAATokenRecord's FindAuthToken call
1179+
rs := testutils.GetEmptyTokenRows("disconnected", "user_guid", "linked_token").
1180+
AddRow(mockTokenGUID, encryptedUAAToken, encryptedUAAToken, mockTokenExpiry, "oauth", "", true)
1181+
mock.ExpectQuery(findUAATokenSQL).
1182+
WillReturnRows(rs)
1183+
1184+
if err := pp.retrieveToken(ctx); err != nil {
1185+
t.Error(err)
1186+
}
1187+
1188+
header := res.Header()
1189+
contentType := header.Get("Content-Type")
1190+
1191+
Convey("Should have expected contentType", func() {
1192+
So(contentType, ShouldContainSubstring, "application/json")
1193+
})
1194+
1195+
Convey("Should return an ok envelope with token data", func() {
1196+
So(res, ShouldNotBeNil)
1197+
body := strings.TrimSpace(res.Body.String())
1198+
So(body, ShouldContainSubstring, `"status":"ok"`)
1199+
So(body, ShouldContainSubstring, `"token_guid":"mock-token-guid"`)
1200+
So(body, ShouldContainSubstring, `"auth_token":"`+mockUAAToken+`"`)
1201+
So(body, ShouldContainSubstring, `"refresh_token":"`+mockUAAToken+`"`)
1202+
})
1203+
})
1204+
}
1205+
1206+
func TestRetrieveTokenNoSessionDate(t *testing.T) {
1207+
t.Parallel()
1208+
1209+
Convey("Test retrieveToken with missing session exp", t, func() {
1210+
1211+
req := setupMockReq("GET", "", map[string]string{
1212+
"username": "admin",
1213+
"password": "changeme",
1214+
})
1215+
res, _, ctx, pp, db, _ := setupHTTPTest(req)
1216+
defer db.Close()
1217+
1218+
if e := pp.InitStratosAuthService(api.Local); e != nil {
1219+
log.Fatalf("Could not initialise auth service: %v", e)
1220+
}
1221+
1222+
// Intentionally omit "exp" so the first session read fails.
1223+
sessionValues := make(map[string]interface{})
1224+
sessionValues["user_id"] = mockUserGUID
1225+
1226+
if errSession := pp.setSessionValues(ctx, sessionValues); errSession != nil {
1227+
t.Error(errors.New("unable to mock/stub user in session object"))
1228+
}
1229+
1230+
err := pp.retrieveToken(ctx)
1231+
Convey("Should not propagate the error (handler writes error envelope instead)", func() {
1232+
So(err, ShouldBeNil)
1233+
})
1234+
1235+
Convey("Should return an error envelope with no token data", func() {
1236+
So(res, ShouldNotBeNil)
1237+
body := strings.TrimSpace(res.Body.String())
1238+
So(body, ShouldContainSubstring, `"status":"error"`)
1239+
So(body, ShouldContainSubstring, `"data":null`)
1240+
})
1241+
})
1242+
}

src/jetstream/plugins/cfapppush/vcs.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,35 @@ type vcsCmd struct {
5555
}
5656

5757
func (vcs *vcsCmd) Create(skipSSL bool, dir string, repo string, branch string) error {
58-
if len(vcs.accessToken) > 0 {
59-
repoURL, err := url.Parse(repo)
60-
if err != nil {
61-
return fmt.Errorf("could not parse repo URL for authenticated clone: %w", err)
62-
}
63-
repoURL.User = url.UserPassword("x-access-token", vcs.accessToken)
64-
repo = repoURL.String()
58+
authenticatedRepo, err := vcs.repoWithToken(repo)
59+
if err != nil {
60+
return err
6561
}
6662

6763
for _, cmd := range vcs.createCmd {
68-
if err := vcs.run(".", cmd, "sslVerify", strconv.FormatBool(!skipSSL), "dir", dir, "repo", repo, "branch", branch); err != nil {
64+
if err := vcs.run(".", cmd, "sslVerify", strconv.FormatBool(!skipSSL), "dir", dir, "repo", authenticatedRepo, "branch", branch); err != nil {
6965
return err
7066
}
7167
}
7268
return nil
7369
}
7470

71+
// repoWithToken rewrites a git repository URL to embed the configured access
72+
// token as basic-auth credentials. When no access token is set it returns the
73+
// URL unchanged. Extracted from Create() so the rewrite can be unit-tested
74+
// without shelling out to a real git binary.
75+
func (vcs *vcsCmd) repoWithToken(repo string) (string, error) {
76+
if len(vcs.accessToken) == 0 {
77+
return repo, nil
78+
}
79+
repoURL, err := url.Parse(repo)
80+
if err != nil {
81+
return "", fmt.Errorf("could not parse repo URL for authenticated clone: %w", err)
82+
}
83+
repoURL.User = url.UserPassword("x-access-token", vcs.accessToken)
84+
return repoURL.String(), nil
85+
}
86+
7587
func (vcs *vcsCmd) ResetBranchToCommit(dir string, commit string) error {
7688
for _, cmd := range vcs.resetToCommitCmd {
7789
if err := vcs.run(dir, cmd, "commit", commit); err != nil {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package cfapppush
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestRepoWithToken_NoTokenReturnsURLUnchanged(t *testing.T) {
9+
vcs := &vcsCmd{accessToken: ""}
10+
11+
out, err := vcs.repoWithToken("https://github.com/org/repo.git")
12+
if err != nil {
13+
t.Fatalf("unexpected error: %v", err)
14+
}
15+
if out != "https://github.com/org/repo.git" {
16+
t.Errorf("expected URL to be unchanged when no token is set, got %q", out)
17+
}
18+
}
19+
20+
func TestRepoWithToken_EmbedsTokenAsBasicAuth(t *testing.T) {
21+
vcs := &vcsCmd{accessToken: "ghp_testtoken123"}
22+
23+
out, err := vcs.repoWithToken("https://github.example.com/org/repo.git")
24+
if err != nil {
25+
t.Fatalf("unexpected error: %v", err)
26+
}
27+
if !strings.Contains(out, "x-access-token:ghp_testtoken123@github.example.com") {
28+
t.Errorf("expected URL to contain x-access-token basic-auth, got %q", out)
29+
}
30+
if !strings.HasPrefix(out, "https://") {
31+
t.Errorf("expected URL to preserve https scheme, got %q", out)
32+
}
33+
if !strings.HasSuffix(out, "/org/repo.git") {
34+
t.Errorf("expected URL to preserve path, got %q", out)
35+
}
36+
}
37+
38+
func TestRepoWithToken_InvalidURLReturnsError(t *testing.T) {
39+
vcs := &vcsCmd{accessToken: "ghp_testtoken123"}
40+
41+
// net/url accepts a lot — use a control character to force a parse error.
42+
_, err := vcs.repoWithToken("https://\x7fexample.com/repo.git")
43+
if err == nil {
44+
t.Fatal("expected error on invalid URL, got nil")
45+
}
46+
if !strings.Contains(err.Error(), "could not parse repo URL") {
47+
t.Errorf("expected error to mention 'could not parse repo URL', got %v", err)
48+
}
49+
}
50+
51+
func TestRepoWithToken_SpecialCharactersInTokenEscaped(t *testing.T) {
52+
// A token containing URL-special chars (@, :, /, etc.) must be
53+
// percent-encoded so downstream git clone doesn't misparse it.
54+
vcs := &vcsCmd{accessToken: "abc@def:ghi/jkl"}
55+
56+
out, err := vcs.repoWithToken("https://github.example.com/org/repo.git")
57+
if err != nil {
58+
t.Fatalf("unexpected error: %v", err)
59+
}
60+
// Raw "@" in the token would end the userinfo section prematurely and
61+
// point git at the wrong host. Percent-encoding prevents that.
62+
if strings.Contains(out, "abc@def") {
63+
t.Errorf("expected token special-chars to be percent-encoded, got %q", out)
64+
}
65+
// URL-encoded "@" is %40
66+
if !strings.Contains(out, "abc%40def") {
67+
t.Errorf("expected token to be percent-encoded, got %q", out)
68+
}
69+
}
70+
71+
func TestGetVCS_ReturnsFreshCopyNotPrototype(t *testing.T) {
72+
// GetVCS must return a copy so callers can't mutate the package-level
73+
// prototype and poison subsequent unrelated git operations.
74+
a := GetVCS(withAccessToken("token-a"))
75+
b := GetVCS() // no options
76+
77+
if b.accessToken != "" {
78+
t.Errorf("expected b.accessToken to be empty (no options applied), got %q", b.accessToken)
79+
}
80+
if a.accessToken != "token-a" {
81+
t.Errorf("expected a.accessToken to be 'token-a', got %q", a.accessToken)
82+
}
83+
// Mutating a must not affect b
84+
a.accessToken = "mutated"
85+
if b.accessToken == "mutated" {
86+
t.Error("mutation on one GetVCS() result leaked into another call")
87+
}
88+
// Prototype must also be untouched
89+
if vcsGit.accessToken != "" {
90+
t.Errorf("package-level vcsGit prototype was mutated; accessToken=%q", vcsGit.accessToken)
91+
}
92+
}
93+
94+
func TestGetVCS_WithAccessTokenOption(t *testing.T) {
95+
vcs := GetVCS(withAccessToken("token-xyz"))
96+
97+
if vcs.accessToken != "token-xyz" {
98+
t.Errorf("expected accessToken to be 'token-xyz', got %q", vcs.accessToken)
99+
}
100+
// Other fields should still be populated from the prototype
101+
if vcs.name != "Git" {
102+
t.Errorf("expected name to be 'Git', got %q", vcs.name)
103+
}
104+
if vcs.cmd != "git" {
105+
t.Errorf("expected cmd to be 'git', got %q", vcs.cmd)
106+
}
107+
if len(vcs.createCmd) == 0 {
108+
t.Error("expected createCmd to be populated from prototype, got empty slice")
109+
}
110+
}

0 commit comments

Comments
 (0)