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
33 changes: 33 additions & 0 deletions test/integration/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SimpleAuth integration-test harness — self-contained, does NOT touch the
# production docker-compose.yml at the repo root.
COMPOSE := docker compose -f docker-compose.yml
CADIR := $(CURDIR)/certs

.PHONY: certs up down test logs ps clean

## certs: generate the internal CA + central TLS cert (idempotent)
certs:
@./certs/gen.sh

## up: stand up the topology (browse https://localhost:9443/admin)
up: certs
$(COMPOSE) up -d --build

## down: stop everything and wipe volumes
down:
$(COMPOSE) down -v --remove-orphans

## ps / logs: inspect the running stack
ps:
$(COMPOSE) ps
logs:
$(COMPOSE) logs -f

## test: up + run the Go scenario driver (from the repo root) + tear down
test: up
@cd ../.. && ITEST_CA_FILE=$(CADIR)/ca.crt go test -tags integration -count=1 -v ./test/integration/ ; \
rc=$$? ; $(COMPOSE) down -v --remove-orphans ; exit $$rc

## clean: down + remove generated certs
clean: down
@rm -f certs/ca.crt certs/ca.key certs/central.crt certs/central.key
71 changes: 71 additions & 0 deletions test/integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Integration harness — standalone → central migration

A self-contained, multi-container environment that exercises the v2.2 migration
feature end to end, across real container boundaries (real TLS, real HTTP push) —
the parts the Go unit tests can't reach. **It does not touch the production
`docker-compose.yml` at the repo root.**

```bash
cd test/integration
make test # up + run the Go scenario driver + tear down
make up # just stand up the env; browse https://localhost:9443/admin
make down # tear down + wipe volumes
make logs # follow logs
```

Requires Docker + Compose + openssl + Go (the driver runs on the host).

## Topology

| Service | Role | Store / dir |
|---|---|---|
| `postgres` | central's database | — |
| `central` | the ONE centralized SimpleAuth (HTTPS, cert SAN=`central`,`localhost`) | Postgres |
| `standalone-local` | local-accounts deployment → migrates in | BoltDB |
| `standalone-ad` | **same-AD** deployment → policy-only migration | BoltDB + corp.local |
| `standalone-addiff` | **different-AD** deployment → blocked at preflight | BoltDB + other.local |
| `ldap-corp` / `ldap-other` | OpenLDAP directories (`uid`/seed LDIF) | corp.local / other.local |

Published to the host (loopback only): central `https://localhost:9443`,
standalone-local `:9444`, standalone-ad `:9445`, standalone-addiff `:9446`.

### Cross-container TLS

The migration's transport guard requires **https for any non-loopback target**,
and the standalone's outbound client verifies the cert. So `certs/gen.sh` mints an
internal CA + a `central` cert; the standalone trusts the CA via
`SSL_CERT_FILE=/certs/ca.crt`. The standalone pushes to `https://central:8080`
(the internal name in the cert's SANs). This makes the harness validate the real
secure path, not a bypass.

## What the driver asserts (`driver_test.go`, `//go:build integration`)

`TestLocalToCentralMigration`:
1. seeds the standalone (role catalog + a local user `alice` with role `admin`);
2. on the central, creates a fresh target app `billing` and mints a single-use
migration token;
3. confirms a **cleartext `http://` push is refused** (the transport guard);
4. runs **preflight** (dry run) over TLS and checks the report (1 local user, none
blocked);
5. **commits**, and verifies `alice → [admin]` landed in the central's `billing`
authz;
6. logs `alice` in **against the central** with her original password (carried
hash) and asserts the token carries `roles=[admin]`, `perms` incl.
`invoice:write`, and the **carried source audience** (not the target app id —
that's what lets the consumer app keep its existing token validation).

## AD scenarios (`ad_test.go`)

`TestADMigrationScenarios` drives the AD half of the matrix against the two
OpenLDAP directories. SimpleAuth is pointed at `username_attr=uid`, so
`User.SAMAccountName` is populated from `uid` and vanilla OpenLDAP suffices (no
samba schema); AD users are provisioned via the login/JIT path.

- **same-AD** — `bob` logs into `standalone-ad`, gets a role, and is migrated to
the central **policy-only** (no record/password copied, `local_users=0`); he
then **re-binds from the same AD on the central** and resolves his migrated
role.
- **different-AD** — `carol` (other.local) is **blocked at preflight** (the
central is on corp.local) and commit is refused (409).
- **direct app** — an app registered straight on the central; `bob` authenticates
directly against it (no migration).
168 changes: 168 additions & 0 deletions test/integration/ad_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//go:build integration

package integration

import (
"encoding/json"
"net/http"
"testing"
"time"

"simpleauth/internal/migrate"
)

// ldapConfig builds the SimpleAuth LDAP config payload. username_attr=uid means
// User.SAMAccountName is populated from `uid` — so vanilla OpenLDAP works and the
// migration (which keys on sAMAccountName) sees the uid value.
func ldapConfig(url, baseDN, domain string) map[string]any {
return map[string]any{
"url": url, "base_dn": baseDN, "bind_dn": "cn=admin," + baseDN,
"bind_password": "adminpw", "username_attr": "uid",
"display_name_attr": "cn", "email_attr": "mail", "groups_attr": "memberOf",
"use_tls": false, "allow_insecure": true, "domain": domain,
}
}

// loginRetry retries an LDAP login until it succeeds (OpenLDAP takes a while to
// bootstrap its seed LDIF on first boot) and returns the access token.
func loginRetry(t *testing.T, n *node, username, password, appID string) string {
t.Helper()
body := map[string]any{"username": username, "password": password}
if appID != "" {
body["app_id"] = appID
}
deadline := time.Now().Add(90 * time.Second)
for {
code, data := n.do(t, "POST", "/api/auth/login", body)
if code == http.StatusOK {
var r struct {
AccessToken string `json:"access_token"`
}
json.Unmarshal(data, &r)
return r.AccessToken
}
if time.Now().After(deadline) {
t.Fatalf("login %q never succeeded (last %d: %s)", username, code, data)
}
time.Sleep(3 * time.Second)
}
}

func findGUIDBySAM(t *testing.T, n *node, sam string) string {
t.Helper()
var users []struct {
GUID string `json:"guid"`
SAM string `json:"sam_account_name"`
}
json.Unmarshal(n.must(t, "GET", "/api/admin/users", nil), &users)
for _, u := range users {
if u.SAM == sam {
return u.GUID
}
}
t.Fatalf("user with sAMAccountName=%q not found on %s", sam, n.base)
return ""
}

// TestADMigrationScenarios covers the AD half of the matrix against real
// OpenLDAP directories: a same-AD policy-only migration (AD user re-binds on the
// central), a different-AD migration that is blocked at preflight, and an app
// registered directly on the central.
func TestADMigrationScenarios(t *testing.T) {
central := &node{centralURL, centralKey, caClient()}
sAD := &node{env("ITEST_STANDALONE_AD_URL", "http://127.0.0.1:9445"), "ad-admin-key", &http.Client{Timeout: 20 * time.Second}}
sDiff := &node{env("ITEST_STANDALONE_ADDIFF_URL", "http://127.0.0.1:9446"), "addiff-admin-key", &http.Client{Timeout: 20 * time.Second}}

waitReady(t, "central", centralURL, central.c)
waitReady(t, "standalone-ad", sAD.base, sAD.c)
waitReady(t, "standalone-addiff", sDiff.base, sDiff.c)

// central + standalone-ad share corp.local; standalone-addiff is on other.local.
central.must(t, "PUT", "/api/admin/ldap", ldapConfig("ldap://ldap-corp:389", "dc=corp,dc=local", "corp.local"))
sAD.must(t, "PUT", "/api/admin/ldap", ldapConfig("ldap://ldap-corp:389", "dc=corp,dc=local", "corp.local"))
sDiff.must(t, "PUT", "/api/admin/ldap", ldapConfig("ldap://ldap-other:389", "dc=other,dc=local", "other.local"))

t.Run("same_ad_policy_migration", func(t *testing.T) {
sAD.must(t, "PUT", "/api/admin/permissions", []string{"hr:read", "hr:write"})
sAD.must(t, "PUT", "/api/admin/role-permissions", map[string][]string{"editor": {"hr:read", "hr:write"}})

// bob (AD) logs into the standalone -> JIT-provisioned; give him a role.
loginRetry(t, sAD, "bob", "bobpass", "")
bob := findGUIDBySAM(t, sAD, "bob")
sAD.must(t, "PUT", "/api/admin/users/"+bob+"/roles", []string{"editor"})

central.must(t, "POST", "/api/admin/apps", map[string]any{"app_id": "hr", "audience": "hr"})
var tok struct {
Token string `json:"migration_token"`
}
json.Unmarshal(central.must(t, "POST", "/api/admin/apps/hr/migration-token", nil), &tok)
mig := map[string]any{"central_url": centralInternal, "app_id": "hr", "token": tok.Token, "carry_secret": true}

// Policy-only: an AD user, no local-user record/password is copied.
var rep migrate.Report
json.Unmarshal(sAD.must(t, "POST", "/api/admin/migrate-to-central/preflight", mig), &rep)
if !rep.OK() || rep.ADUsersSameDomain != 1 || rep.LocalUsers != 0 {
t.Fatalf("preflight: want 1 same-AD user, 0 local, none blocked; got %+v", rep)
}
var res migrate.ApplyResult
json.Unmarshal(sAD.must(t, "POST", "/api/admin/migrate-to-central/commit", mig), &res)
if res.AssignmentsSet != 1 || res.LocalUsersCreated != 0 {
t.Fatalf("commit (policy-only) result: %+v", res)
}

var az struct {
UserAssignments map[string][]string `json:"user_assignments"`
}
json.Unmarshal(central.must(t, "GET", "/api/admin/apps/hr/authz", nil), &az)
if got := az.UserAssignments["bob"]; len(got) != 1 || got[0] != "editor" {
t.Fatalf("central hr assignment for bob = %v, want [editor]", got)
}

// bob RE-BINDS from the SAME AD on the central and resolves his migrated role.
roles, perms, _ := tokenClaims(t, loginRetry(t, central, "bob", "bobpass", "hr"))
if !has(roles, "editor") || !has(perms, "hr:write") {
t.Fatalf("central hr token roles=%v perms=%v, want editor/hr:write", roles, perms)
}
t.Logf("OK same-AD: bob migrated policy-only, re-binds on central roles=%v perms=%v", roles, perms)
})

t.Run("different_ad_blocked", func(t *testing.T) {
sDiff.must(t, "PUT", "/api/admin/role-permissions", map[string][]string{"viewer": {}})
loginRetry(t, sDiff, "carol", "carolpass", "")
carol := findGUIDBySAM(t, sDiff, "carol")
sDiff.must(t, "PUT", "/api/admin/users/"+carol+"/roles", []string{"viewer"})

central.must(t, "POST", "/api/admin/apps", map[string]any{"app_id": "diffapp", "audience": "diffapp"})
var tok struct {
Token string `json:"migration_token"`
}
json.Unmarshal(central.must(t, "POST", "/api/admin/apps/diffapp/migration-token", nil), &tok)
mig := map[string]any{"central_url": centralInternal, "app_id": "diffapp", "token": tok.Token}

var rep migrate.Report
json.Unmarshal(sDiff.must(t, "POST", "/api/admin/migrate-to-central/preflight", mig), &rep)
if rep.OK() || len(rep.Blocked) == 0 {
t.Fatalf("different-AD must block carol at preflight; got %+v", rep)
}
// And commit is refused (the central re-runs the classifier).
if code, data := sDiff.do(t, "POST", "/api/admin/migrate-to-central/commit", mig); code != http.StatusConflict {
t.Fatalf("different-AD commit must be 409, got %d: %s", code, data)
}
t.Logf("OK different-AD: carol blocked at preflight + commit (%d blocked)", len(rep.Blocked))
})

t.Run("direct_app_on_central", func(t *testing.T) {
central.must(t, "POST", "/api/admin/apps", map[string]any{"app_id": "portal", "audience": "portal"})
central.must(t, "PUT", "/api/admin/apps/portal/authz", map[string]any{
"roles": []string{"viewer"},
"role_permissions": map[string][]string{"viewer": {"portal:read"}},
"user_assignments": map[string][]string{"bob": {"viewer"}},
})
// bob (corp AD) authenticates DIRECTLY against the central for portal.
roles, perms, aud := tokenClaims(t, loginRetry(t, central, "bob", "bobpass", "portal"))
if !has(roles, "viewer") || !has(perms, "portal:read") || !has(aud, "portal") {
t.Fatalf("direct portal token roles=%v perms=%v aud=%v", roles, perms, aud)
}
t.Logf("OK direct: bob authenticates directly on central for portal roles=%v", roles)
})
}
5 changes: 5 additions & 0 deletions test/integration/certs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Generated by gen.sh — never commit keys/certs.
*.crt
*.key
*.csr
*.srl
28 changes: 28 additions & 0 deletions test/integration/certs/gen.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Generates an internal CA + a TLS cert for the `central` service. The SANs cover
# the compose service name (`central`, used by standalones pushing over the
# internal network) AND loopback (`localhost`/`127.0.0.1`, used by the host-side
# driver), so both validate against the same CA. Idempotent — delete *.crt/*.key
# to regenerate.
set -euo pipefail
cd "$(dirname "$0")"

if [[ -f ca.crt && -f central.crt && -f central.key ]]; then
echo "certs already present (delete certs/*.crt certs/*.key to regenerate)"
exit 0
fi

echo "generating internal CA + central cert..."
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 \
-subj "/CN=SimpleAuth Integration Test CA" >/dev/null 2>&1

openssl req -newkey rsa:2048 -nodes -keyout central.key -out central.csr \
-subj "/CN=central" >/dev/null 2>&1

ext="$(mktemp)"
printf "subjectAltName=DNS:central,DNS:localhost,IP:127.0.0.1\n" > "$ext"
openssl x509 -req -in central.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out central.crt -days 3650 -extfile "$ext" >/dev/null 2>&1
rm -f central.csr ca.srl "$ext"

echo " -> certs/ca.crt, certs/central.crt, certs/central.key (SAN: central, localhost, 127.0.0.1)"
Loading