Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

jobs:
build:
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -93,6 +95,12 @@ jobs:
working-directory: cmd
run: go test -race ./...

- name: Test (cmd, bridge networking)
working-directory: cmd
env:
JAWSAUTH_TEST_FORCE_BRIDGE: "1"
run: go test -race ./...

- name: Calculate code coverage
id: coverage
run: |
Expand All @@ -113,6 +121,30 @@ jobs:
working-directory: cmd
run: go build -v ./...

cmd:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
cache-dependency-path: cmd/go.sum

- name: Test (cmd)
working-directory: cmd
run: go test -race -v ./...

- name: Test (cmd, bridge networking)
working-directory: cmd
env:
JAWSAUTH_TEST_FORCE_BRIDGE: "1"
run: go test -race -v ./...

coverage:
needs: build
runs-on: ubuntu-latest
Expand Down
63 changes: 51 additions & 12 deletions cmd/demo/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,62 @@ import (
"strings"
"testing"
"time"

"github.com/linkdata/jawsauth/cmd/internal/testdocker"
"github.com/moby/moby/api/types/container"
"github.com/testcontainers/testcontainers-go"
)

func TestIntegrationLoginWorks(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute)
defer cancel()
opts := demoOptions{
ListenAddr: "127.0.0.1:0",
PasswordFile: filepath.Join(t.TempDir(), "password.txt"),
Realm: "jawsauth-demo-it",
ClientID: "jawsauth-demo-it-client",
Username: "demouser",
UserEmail: "demouser@example.com",
KeycloakImage: testdocker.KeycloakImage,
}
oldDefaultHost := keycloakDefaultHost
keycloakDefaultHost = func() (string, error) { return "127.0.0.1", nil }
t.Cleanup(func() { keycloakDefaultHost = oldDefaultHost })
hostNetwork := testdocker.HostNetworkAvailable(ctx, opts.KeycloakImage)
t.Logf("Docker host network available: %t", hostNetwork)
portInUse := errors.New("keycloak port already in use")
attempts := 1
if hostNetwork {
attempts = 3
oldHTTPSPort := keycloakHTTPSPort
t.Cleanup(func() { keycloakHTTPSPort = oldHTTPSPort })
oldContainer := keycloakContainer
keycloakContainer = func(ctx context.Context, req testcontainers.GenericContainerRequest) (testcontainers.Container, error) {
req.Cmd = append(req.Cmd, "--http-host=127.0.0.1")
req.HostConfigModifier = func(hc *container.HostConfig) { hc.NetworkMode = "host" }
started, err := oldContainer(ctx, req)
if err != nil && testdocker.PortInUse(ctx, started) {
err = errors.Join(err, portInUse)
}
return started, err
}
t.Cleanup(func() { keycloakContainer = oldContainer })
}

passwordFile := filepath.Join(t.TempDir(), "password.txt")
demo, err := startDemo(ctx, demoOptions{
ListenAddr: "127.0.0.1:0",
PasswordFile: passwordFile,
Realm: "jawsauth-demo-it",
ClientID: "jawsauth-demo-it-client",
Username: "demouser",
UserEmail: "demouser@example.com",
})
var demo *demoServer
var err error
for range attempts {
if hostNetwork {
keycloakHTTPSPort, err = testdocker.FreePort()
if err != nil {
t.Fatal(err)
}
}
demo, err = startDemo(ctx, opts)
if !errors.Is(err, portInUse) {
break
}
}
if err != nil {
if isDockerUnavailableError(err) {
t.Skipf("docker unavailable: %v", err)
Expand All @@ -46,7 +85,7 @@ func TestIntegrationLoginWorks(t *testing.T) {
}
})

passwordBytes, err := os.ReadFile(passwordFile)
passwordBytes, err := os.ReadFile(opts.PasswordFile)
if err != nil {
t.Fatal(err)
}
Expand Down
16 changes: 11 additions & 5 deletions cmd/demo/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ var (
keycloakGeneratePEM = generateSelfSignedCertificatePEM
keycloakContainer = testcontainers.GenericContainer
keycloakDefaultHost = defaultInterfaceAddress
keycloakHTTPSPort = "8443"
)

func startKeycloakServer(ctx context.Context, image, adminPassword string) (*keycloakServer, error) {
Expand Down Expand Up @@ -90,23 +91,23 @@ func startKeycloakServer(ctx context.Context, image, adminPassword string) (*key

req := testcontainers.ContainerRequest{
Image: image,
ExposedPorts: []string{"8443/tcp"},
ExposedPorts: []string{keycloakHTTPSPort + "/tcp"},
Env: map[string]string{
"KC_BOOTSTRAP_ADMIN_USERNAME": defaultKeycloakAdminUser,
"KC_BOOTSTRAP_ADMIN_PASSWORD": adminPassword,
"KC_HTTP_ENABLED": "false",
"KC_HTTPS_PORT": "8443",
"KC_HTTPS_PORT": keycloakHTTPSPort,
"KC_HTTPS_CERTIFICATE_FILE": "/opt/keycloak/conf/server.crt",
"KC_HTTPS_CERTIFICATE_KEY_FILE": "/opt/keycloak/conf/server.key",
"KC_HOSTNAME_STRICT": "false",
},
Cmd: []string{"start-dev", "--https-port=8443", "--http-enabled=false"},
Cmd: []string{"start-dev", "--https-port=" + keycloakHTTPSPort, "--http-enabled=false"},
Files: []testcontainers.ContainerFile{
{HostFilePath: certPath, ContainerFilePath: "/opt/keycloak/conf/server.crt", FileMode: 0o644},
{HostFilePath: keyPath, ContainerFilePath: "/opt/keycloak/conf/server.key", FileMode: 0o644},
},
WaitingFor: wait.ForHTTP("/").
WithPort("8443/tcp").
WithPort(keycloakHTTPSPort + "/tcp").
WithTLS(true).
WithAllowInsecure(true).
WithStartupTimeout(2 * time.Minute),
Expand All @@ -117,6 +118,11 @@ func startKeycloakServer(ctx context.Context, image, adminPassword string) (*key
Started: true,
})
if err != nil {
if container != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
_ = container.Terminate(cleanupCtx, testcontainers.StopTimeout(0))
cancel()
}
cleanupTempDir()
return nil, fmt.Errorf("start keycloak container: %w", err)
}
Expand All @@ -128,7 +134,7 @@ func startKeycloakServer(ctx context.Context, image, adminPassword string) (*key
return nil, fmt.Errorf("resolve keycloak host: %w", err)
}

port, err := container.MappedPort(ctx, "8443/tcp")
port, err := container.MappedPort(ctx, keycloakHTTPSPort+"/tcp")
if err != nil {
_ = container.Terminate(ctx)
cleanupTempDir()
Expand Down
18 changes: 18 additions & 0 deletions cmd/demo/keycloak_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type fakeContainer struct {
hostErr error
portErr error
terminateErr error
terminated *bool
}

func (container fakeContainer) Host(context.Context) (string, error) {
Expand All @@ -69,6 +70,9 @@ func (container fakeContainer) MappedPort(context.Context, string) (network.Port
}

func (container fakeContainer) Terminate(context.Context, ...testcontainers.TerminateOption) error {
if container.terminated != nil {
*container.terminated = true
}
return container.terminateErr
}

Expand Down Expand Up @@ -146,6 +150,20 @@ func TestStartKeycloakServerErrorsAndSuccess(t *testing.T) {
})
}

t.Run("container start failure cleans up", func(t *testing.T) {
resetKeycloakDeps(t)
var terminated bool
keycloakContainer = func(context.Context, testcontainers.GenericContainerRequest) (testcontainers.Container, error) {
return fakeContainer{terminated: &terminated}, errors.New("start failure")
}
if _, err := startKeycloakServer(t.Context(), "image", "admin-pass"); err == nil {
t.Fatal("expected container startup failure")
}
if !terminated {
t.Fatal("failed container was not terminated")
}
})

t.Run("success", func(t *testing.T) {
resetKeycloakDeps(t)
keycloakContainer = func(context.Context, testcontainers.GenericContainerRequest) (testcontainers.Container, error) {
Expand Down
65 changes: 50 additions & 15 deletions cmd/integration/keycloak_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import (
"testing"
"time"

"github.com/linkdata/jawsauth/cmd/internal/testdocker"
"github.com/moby/moby/api/types/container"
"github.com/testcontainers/testcontainers-go"
)

func TestKeycloakFlow(t *testing.T) {
ctx := t.Context()

// Start Keycloak container
keycloakContainer, err := startKeycloakContainer(t, ctx)
keycloakContainer, containerPort, err := startKeycloakContainer(t, ctx)
defer func() {
if keycloakContainer != nil {
if t.Failed() {
Expand All @@ -39,7 +41,7 @@ func TestKeycloakFlow(t *testing.T) {
t.Fatalf("Failed to get container host: %v", err)
}

port, err := keycloakContainer.MappedPort(ctx, "8080/tcp")
port, err := keycloakContainer.MappedPort(ctx, containerPort)
if err != nil {
t.Fatalf("Failed to get container port: %v", err)
}
Expand Down Expand Up @@ -95,32 +97,58 @@ func TestKeycloakFlow(t *testing.T) {
serverHandlerTest(t, baseURL, realm, "testclient", clientSecret)
}

func startKeycloakContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) {
func startKeycloakContainer(t *testing.T, ctx context.Context) (testcontainers.Container, string, error) {
t.Helper()
defer func() {
if x := recover(); x != nil {
t.Skip("failed to start keycloak container", x)
}
}()
req := testcontainers.ContainerRequest{
Image: "quay.io/keycloak/keycloak:latest",
Image: testdocker.KeycloakImage,
ExposedPorts: []string{"8080/tcp"},
Env: map[string]string{
"KC_BOOTSTRAP_ADMIN_USERNAME": "admin",
"KC_BOOTSTRAP_ADMIN_PASSWORD": "admin",
},
Cmd: []string{"start-dev"},
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start container: %w", err)
hostNetwork := testdocker.HostNetworkAvailable(ctx, req.Image)
t.Logf("Docker host network available: %t", hostNetwork)
attempts := 1
if hostNetwork {
attempts = 3
req.HostConfigModifier = func(hc *container.HostConfig) { hc.NetworkMode = "host" }
}
containerPort := "8080/tcp"
var lastErr error
for range attempts {
if hostNetwork {
port, err := testdocker.FreePort()
if err != nil {
return nil, "", err
}
containerPort = port + "/tcp"
req.Cmd = []string{"start-dev", "--http-host=127.0.0.1", "--http-port=" + port}
}
started, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err == nil {
err = waitForKeycloak(ctx, started, containerPort)
}
if err == nil {
return started, containerPort, nil
}
if hostNetwork && testdocker.PortInUse(ctx, started) {
lastErr = err
_ = started.Terminate(context.WithoutCancel(ctx))
continue
}
return started, containerPort, fmt.Errorf("failed to start container: %w", err)
}

return container, waitForKeycloak(ctx, container)
return nil, "", fmt.Errorf("keycloak port was occupied on all %d attempts: %w", attempts, lastErr)
}

func printLogs(ctx context.Context, container testcontainers.Container) {
Expand Down Expand Up @@ -404,20 +432,27 @@ func getUserInfoEmail(ctx context.Context, baseURL, realm, accessToken string) (
return email, nil
}

func waitForKeycloak(ctx context.Context, container testcontainers.Container) error {
func waitForKeycloak(ctx context.Context, container testcontainers.Container, containerPort string) error {
host, err := container.Host(ctx)
if err != nil {
return fmt.Errorf("failed to get container host: %w", err)
}

mappedPort, err := container.MappedPort(ctx, "8080/tcp")
mappedPort, err := container.MappedPort(ctx, containerPort)
if err != nil {
return fmt.Errorf("failed to get mapped port: %w", err)
}

url := fmt.Sprintf("http://%s:%s/", host, mappedPort.Port())

for range 20 { // Retry for ~60 seconds (20 attempts, 3 seconds each)
state, err := container.State(ctx)
if err != nil {
return err
}
if !state.Running {
return fmt.Errorf("keycloak container exited with code %d", state.ExitCode)
}
resp, err := http.Get(url)
if err == nil {
statusCode := resp.StatusCode
Expand Down
Loading
Loading