terraform-provider-woodpecker/internal/internal_test.go

571 lines
14 KiB
Go

package internal_test
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"net/http/cookiejar"
urlpkg "net/url"
"os"
"strconv"
"strings"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"github.com/Kichiyaki/terraform-provider-woodpecker/internal/woodpecker"
"github.com/google/uuid"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"golang.org/x/oauth2"
)
var (
giteaClient *gitea.Client
woodpeckerClient woodpecker.Client
)
func TestMain(m *testing.M) {
pool := newDockertestPool()
var deferFuncs []func()
network := newDockerNetwork(pool)
deferFuncs = append(deferFuncs, func() {
_ = network.Close()
})
resourceGitea := runGitea(pool, network)
deferFuncs = append(deferFuncs, func() {
_ = resourceGitea.Close()
})
giteaClient = newGiteaClient(resourceGitea.httpURL, resourceGitea.user)
resourceWoodpecker := runWoodpecker(
pool,
network,
resourceGitea.httpURL,
resourceGitea.privateHTTPURL,
resourceGitea.user,
)
deferFuncs = append(deferFuncs, func() {
_ = resourceWoodpecker.Close()
})
woodpeckerClient = newWoodpeckerClient(resourceWoodpecker.httpURL, resourceWoodpecker.token)
// set required envs
_ = os.Setenv("TF_ACC", "1")
_ = os.Setenv("WOODPECKER_SERVER", resourceWoodpecker.httpURL.String())
_ = os.Setenv("WOODPECKER_TOKEN", resourceWoodpecker.token)
code := m.Run()
// LIFO
for i := len(deferFuncs) - 1; i >= 0; i-- {
deferFuncs[i]()
}
os.Exit(code)
}
func newDockertestPool() *dockertest.Pool {
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("couldn't construct pool: %s", err)
}
if err = pool.Client.Ping(); err != nil {
log.Fatalf("couldn't connect to Docker: %s", err)
}
return pool
}
func newDockerNetwork(pool *dockertest.Pool) *dockertest.Network {
network, err := pool.CreateNetwork(uuid.NewString())
if err != nil {
log.Fatalln("couldn't create docker network:", err)
}
return network
}
const giteaContainerExpInSec = 120
type giteaResource struct {
docker *dockertest.Resource
httpURL *urlpkg.URL
privateHTTPURL *urlpkg.URL
user *urlpkg.Userinfo
}
func runGitea(pool *dockertest.Pool, network *dockertest.Network) giteaResource {
repo, tag := getGiteaRepoTag()
giteaRsc, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: repo,
Tag: tag,
Networks: []*dockertest.Network{network},
Env: []string{
"GITEA__security__INSTALL_LOCK=true",
},
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{
Name: "no",
}
})
if err != nil {
log.Fatalf("couldn't run gitea: %s", err)
}
if err = giteaRsc.Expire(giteaContainerExpInSec); err != nil {
log.Fatal(err)
}
httpURL := &urlpkg.URL{
Scheme: "http",
Host: getHostPort(giteaRsc, "3000/tcp"),
}
if err = pool.Retry(func() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, httpURL.String(), nil)
if reqErr != nil {
return reqErr
}
res, resErr := (&http.Client{}).Do(req)
if resErr != nil {
return resErr
}
defer func() {
_ = res.Body.Close()
}()
_, _ = io.Copy(io.Discard, res.Body)
if res.StatusCode != http.StatusOK {
return errors.New("request to gitea failed")
}
return nil
}); err != nil {
log.Fatal(err)
}
return giteaResource{
docker: giteaRsc,
httpURL: httpURL,
privateHTTPURL: &urlpkg.URL{
Scheme: httpURL.Scheme,
Host: giteaRsc.GetIPInNetwork(network) + ":3000",
},
user: createGiteaUser(pool, giteaRsc),
}
}
func (r giteaResource) Close() error {
return r.docker.Close()
}
const defaultGiteaImage = "gitea/gitea:1.20"
//nolint:nonamedreturns
func getGiteaRepoTag() (repository string, tag string) {
val := os.Getenv("GITEA_IMAGE")
if val == "" {
val = defaultGiteaImage
}
return docker.ParseRepositoryTag(val)
}
func createGiteaUser(pool *dockertest.Pool, giteaRsc *dockertest.Resource) *urlpkg.Userinfo {
username := strings.ReplaceAll(uuid.NewString(), "-", "")
password := uuid.NewString()
stdOutBuf := bytes.NewBuffer(nil)
stdErrBuf := bytes.NewBuffer(nil)
exec, err := pool.Client.CreateExec(docker.CreateExecOptions{
Container: giteaRsc.Container.ID,
User: "git",
Cmd: []string{
"gitea",
"admin",
"user",
"create",
"--admin",
"--username=" + username,
"--password=" + password,
fmt.Sprintf("--email=%s@localhost", username),
},
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
log.Fatalln("couldn't create exec:", err)
}
err = pool.Client.StartExec(exec.ID, docker.StartExecOptions{
OutputStream: stdOutBuf,
ErrorStream: stdErrBuf,
})
if err != nil {
log.Fatalln("couldn't start exec:", err)
}
inspectExec, err := pool.Client.InspectExec(exec.ID)
if err != nil {
log.Fatalln("couldn't inspect exec:", err)
}
if inspectExec.ExitCode != 0 {
log.Fatalln("couldn't create user\nstdout:", stdOutBuf.String(), "\nstderr:", stdErrBuf.String())
}
return urlpkg.UserPassword(username, password)
}
type woodpeckerResource struct {
docker *dockertest.Resource
httpURL *urlpkg.URL
token string
}
const woodpeckerContainerExpInSec = 120
func runWoodpecker(
pool *dockertest.Pool,
network *dockertest.Network,
giteaPublicURL, giteaPrivateURL *urlpkg.URL,
giteaUser *urlpkg.Userinfo,
) woodpeckerResource {
httpURL := &urlpkg.URL{
Scheme: giteaPublicURL.Scheme,
//nolint:gosec
Host: giteaPublicURL.Hostname() + ":" + strconv.Itoa(rand.New(rand.NewSource(time.Now().Unix())).Intn(5000)+35000),
}
oauthApp, _, err := giteaClient.CreateOauth2(gitea.CreateOauth2Option{
Name: "woodpecker",
ConfidentialClient: true,
RedirectURIs: []string{httpURL.String() + "/authorize"},
})
if err != nil {
log.Fatalln("couldn't create oauth2 app:", err)
}
repo, tag := getWoodpeckerRepoTag()
woodpeckerRsc, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: repo,
Tag: tag,
Networks: []*dockertest.Network{network},
PortBindings: map[docker.Port][]docker.PortBinding{
"8000/tcp": {
{
HostPort: httpURL.Port(),
},
},
},
Env: []string{
"WOODPECKER_OPEN=true",
"WOODPECKER_HOST=" + httpURL.String(),
"WOODPECKER_AGENT_SECRET=" + uuid.NewString(),
"WOODPECKER_GITEA=true",
"WOODPECKER_GITEA_URL=" + giteaPrivateURL.String(),
"WOODPECKER_GITEA_CLIENT=" + oauthApp.ClientID,
"WOODPECKER_GITEA_SECRET=" + oauthApp.ClientSecret,
"WOODPECKER_ADMIN=" + giteaUser.Username(),
"WOODPECKER_LOG_LEVEL=debug",
},
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.Mounts = append(config.Mounts, docker.HostMount{
Type: "tmpfs",
Target: "/var/lib/woodpecker",
})
config.RestartPolicy = docker.RestartPolicy{
Name: "no",
}
})
if err != nil {
log.Fatalf("couldn't run woodpecker: %s", err)
}
if err = woodpeckerRsc.Expire(woodpeckerContainerExpInSec); err != nil {
log.Fatal(err)
}
if err = pool.Retry(func() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, httpURL.String(), nil)
if reqErr != nil {
return reqErr
}
res, resErr := (&http.Client{}).Do(req)
if resErr != nil {
return resErr
}
defer func() {
_ = res.Body.Close()
}()
_, _ = io.Copy(io.Discard, res.Body)
if res.StatusCode != http.StatusOK {
return errors.New("request to woodpecker failed")
}
return nil
}); err != nil {
log.Fatal(err)
}
return woodpeckerResource{
docker: woodpeckerRsc,
httpURL: httpURL,
token: newWoodpeckerTokenProvider(oauthApp, giteaUser, giteaPublicURL, httpURL).token(),
}
}
func (r woodpeckerResource) Close() error {
return r.docker.Close()
}
const defaultWoodpeckerImage = "woodpeckerci/woodpecker-server:v2.0.0"
//nolint:nonamedreturns
func getWoodpeckerRepoTag() (repo string, tag string) {
val := os.Getenv("WOODPECKER_IMAGE")
if val == "" {
val = defaultWoodpeckerImage
}
return docker.ParseRepositoryTag(val)
}
type woodpeckerTokenProvider struct {
client *http.Client
oauthApp *gitea.Oauth2
giteaUser *urlpkg.Userinfo
giteaURL *urlpkg.URL
woodpeckerURL *urlpkg.URL
}
func newWoodpeckerTokenProvider(
oauthApp *gitea.Oauth2,
giteaUser *urlpkg.Userinfo,
giteaURL *urlpkg.URL,
woodpeckerURL *urlpkg.URL,
) woodpeckerTokenProvider {
cookieJar, _ := cookiejar.New(nil)
return woodpeckerTokenProvider{
client: &http.Client{
Timeout: 10 * time.Second,
Jar: cookieJar,
},
oauthApp: oauthApp,
giteaUser: giteaUser,
giteaURL: giteaURL,
woodpeckerURL: woodpeckerURL,
}
}
func (p woodpeckerTokenProvider) token() string {
ctx := context.Background()
// we need to make this request to get the csrf token
respGetLoginPage := p.get(ctx, p.giteaURL.String()+"/user/login")
_, _ = io.Copy(io.Discard, respGetLoginPage.Body)
_ = respGetLoginPage.Body.Close()
// log in to Gitea
password, _ := p.giteaUser.Password()
respLogin := p.postForm(ctx, p.giteaURL.String()+"/user/login", urlpkg.Values{
"_csrf": []string{p.getCSRFTokenFromCookies(p.client.Jar.Cookies(p.giteaURL))},
"user_name": []string{p.giteaUser.Username()},
"password": []string{password},
})
_, _ = io.Copy(io.Discard, respLogin.Body)
_ = respLogin.Body.Close()
// we need to make this request to get the csrf token
respAuthorize := p.get(ctx, (&urlpkg.URL{
Scheme: p.giteaURL.Scheme,
Host: p.giteaURL.Host,
Path: "/login/oauth/authorize",
RawQuery: urlpkg.Values{
"client_id": []string{p.oauthApp.ClientID},
"redirect_uri": []string{p.oauthApp.RedirectURIs[0]},
"response_type": []string{"code"},
"state": []string{p.oauthApp.Name},
}.Encode(),
}).String())
_, _ = io.Copy(io.Discard, respAuthorize.Body)
_ = respAuthorize.Body.Close()
// log in to Woodpecker
respGrant := p.postForm(ctx, p.giteaURL.String()+"/login/oauth/grant", urlpkg.Values{
"_csrf": []string{p.getCSRFTokenFromCookies(p.client.Jar.Cookies(p.giteaURL))},
"client_id": []string{p.oauthApp.ClientID},
"redirect_uri": []string{p.oauthApp.RedirectURIs[0]},
"state": []string{p.oauthApp.Name},
"response_type": []string{"code"},
"scope": []string{""},
"nonce": []string{""},
})
_, _ = io.Copy(io.Discard, respGrant.Body)
_ = respGrant.Body.Close()
// we need to make this request to get the csrf token
respWebConfig := p.get(ctx, p.woodpeckerURL.String()+"/web-config.js")
csrfToken := p.readCSRFTokenFromWoodpeckerWebConfig(respWebConfig.Body)
_ = respWebConfig.Body.Close()
if csrfToken == "" {
log.Fatalln("couldn't extract csrf token from woodpecker web config")
}
// finally generate the woodpecker token
reqToken := p.newRequestWithContext(ctx, http.MethodPost, p.woodpeckerURL.String()+"/api/user/token", nil)
reqToken.Header.Set("X-Csrf-Token", csrfToken)
respToken := p.do(reqToken)
token, err := io.ReadAll(respToken.Body)
if err != nil {
log.Fatalln("couldn't read token from response:", err)
}
_ = respToken.Body.Close()
return string(token)
}
func (p woodpeckerTokenProvider) postForm(ctx context.Context, url string, values urlpkg.Values) *http.Response {
req := p.newRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return p.do(req)
}
func (p woodpeckerTokenProvider) get(ctx context.Context, url string) *http.Response {
return p.do(p.newRequestWithContext(ctx, http.MethodGet, url, nil))
}
func (p woodpeckerTokenProvider) newRequestWithContext(
ctx context.Context,
method string,
url string,
body io.Reader,
) *http.Request {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
log.Fatalf("couldn't construct request for url %s: %s", url, err)
}
return req
}
func (p woodpeckerTokenProvider) do(req *http.Request) *http.Response {
resp, err := p.client.Do(req)
if err != nil {
log.Fatalf("request to %s failed: %s", req.URL.String(), err)
}
if resp.StatusCode/100 != 2 { // accept only 2XX requests
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
log.Fatalf("request to %s failed (status code = %d): %s", req.URL.String(), resp.StatusCode, b)
}
return resp
}
func (p woodpeckerTokenProvider) getCSRFTokenFromCookies(cookies []*http.Cookie) string {
for _, cookie := range cookies {
if cookie.Name == "_csrf" {
return cookie.Value
}
}
return ""
}
func (p woodpeckerTokenProvider) readCSRFTokenFromWoodpeckerWebConfig(r io.Reader) string {
defer func() {
// discard remaining bytes
_, _ = io.Copy(io.Discard, r)
}()
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "window.WOODPECKER_CSRF") {
continue
}
return line[strings.Index(line, `"`)+1 : strings.LastIndex(line, `"`)]
}
return ""
}
func getHostPort(resource *dockertest.Resource, id string) string {
dockerURL := os.Getenv("DOCKER_HOST")
if dockerURL == "" {
return resource.GetHostPort(id)
}
u, err := urlpkg.Parse(dockerURL)
if err != nil {
log.Fatalln("couldn't parse DOCKER_HOST:", err)
}
return u.Hostname() + ":" + resource.GetPort(id)
}
func newGiteaClient(url *urlpkg.URL, user *urlpkg.Userinfo) *gitea.Client {
password, _ := user.Password()
client, err := gitea.NewClient(
url.String(),
gitea.SetHTTPClient(&http.Client{
Timeout: 10 * time.Second,
}),
gitea.SetBasicAuth(user.Username(), password),
)
if err != nil {
log.Fatalln("couldn't construct *gitea.Client:", err)
}
if _, _, err = client.GetMyUserInfo(); err != nil {
log.Fatalln("couldn't get user info:", err)
}
return client
}
func newWoodpeckerClient(url *urlpkg.URL, token string) woodpecker.Client {
client := woodpecker.NewClient(
url.String(),
(&oauth2.Config{}).Client(context.Background(), &oauth2.Token{
AccessToken: token,
}),
)
if _, err := client.Self(); err != nil {
log.Fatalln("couldn't get user info:", err)
}
return client
}