a161c44575
Real clients now authenticate every local publish against a static mock OIDC issuer, and each type checks unauthenticated publishes are refused while anonymous reads keep working.
185 lines
5.8 KiB
Go
185 lines
5.8 KiB
Go
//go:build dockere2e
|
|
|
|
// Package e2edocker holds the black-box end-to-end suite that runs against a
|
|
// fully dockerised artifactapi stack (see scripts/docker-e2e.sh). Unlike the
|
|
// in-process e2e suite, these tests only speak HTTP to the running container.
|
|
package e2edocker
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
jose "github.com/go-jose/go-jose/v4"
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
)
|
|
|
|
// authToken is accepted by the stack's write gate; badToken carries the same
|
|
// claims signed by a key the issuer never published.
|
|
var authToken, badToken string
|
|
|
|
// TestMain publishes a fresh signing key through the static mockoidc issuer
|
|
// (e2e-docker/oidc, served by docker-compose.e2e.yml) and mints both tokens.
|
|
func TestMain(m *testing.M) {
|
|
key := mustRSAKey()
|
|
jwks, _ := json.Marshal(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "e2e", Algorithm: "RS256", Use: "sig"}}})
|
|
if err := os.WriteFile(filepath.Join("oidc", "jwks.json"), jwks, 0o644); err != nil {
|
|
log.Fatalf("write jwks: %v", err)
|
|
}
|
|
authToken, badToken = mintToken(key), mintToken(mustRSAKey())
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func mustRSAKey() *rsa.PrivateKey {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
log.Fatalf("generate key: %v", err)
|
|
}
|
|
return key
|
|
}
|
|
|
|
func mintToken(key *rsa.PrivateKey) string {
|
|
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: jose.JSONWebKey{Key: key, KeyID: "e2e"}}, (&jose.SignerOptions{}).WithType("JWT"))
|
|
if err != nil {
|
|
log.Fatalf("signer: %v", err)
|
|
}
|
|
now := time.Now()
|
|
tok, err := jwt.Signed(sig).Claims(jwt.Claims{
|
|
Issuer: "http://mockoidc", Subject: "e2e", Audience: jwt.Audience{"artifactapi"},
|
|
IssuedAt: jwt.NewNumericDate(now), Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
|
|
}).Serialize()
|
|
if err != nil {
|
|
log.Fatalf("sign token: %v", err)
|
|
}
|
|
return tok
|
|
}
|
|
|
|
func basicAuth(token string) string {
|
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte("ci:"+token))
|
|
}
|
|
|
|
func baseURL() string {
|
|
if v := os.Getenv("ARTIFACTAPI_URL"); v != "" {
|
|
return strings.TrimRight(v, "/")
|
|
}
|
|
return "http://localhost:8000"
|
|
}
|
|
|
|
// mockUpstream is the base URL the artifactapi *container* uses to reach the
|
|
// static mock upstream. It is resolved on the compose network, not the host.
|
|
func mockUpstream() string {
|
|
if v := os.Getenv("MOCK_UPSTREAM_INTERNAL"); v != "" {
|
|
return strings.TrimRight(v, "/")
|
|
}
|
|
return "http://mockupstream"
|
|
}
|
|
|
|
func api(path string) string { return baseURL() + path }
|
|
|
|
func fixtureBytes(t *testing.T, rel string) []byte {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join("fixtures", rel))
|
|
if err != nil {
|
|
t.Fatalf("read fixture %s: %v", rel, err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// doRequest sends authToken as a Bearer token; doRequestAuth sends authz
|
|
// verbatim, or no Authorization header when it is empty.
|
|
func doRequest(t *testing.T, method, url string, body []byte, contentType string) (*http.Response, []byte) {
|
|
t.Helper()
|
|
return doRequestAuth(t, method, url, body, contentType, "Bearer "+authToken)
|
|
}
|
|
|
|
func doRequestAuth(t *testing.T, method, url string, body []byte, contentType, authz string) (*http.Response, []byte) {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != nil {
|
|
r = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequest(method, url, r)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, url, err)
|
|
}
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
if authz != "" {
|
|
req.Header.Set("Authorization", authz)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return resp, respBody
|
|
}
|
|
|
|
func createRepo(t *testing.T, jsonBody string) {
|
|
t.Helper()
|
|
resp, body := doRequest(t, http.MethodPost, api("/api/v2/remotes"), []byte(jsonBody), "application/json")
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create repo: status %d: %s", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
func deleteRepo(t *testing.T, name string) {
|
|
t.Helper()
|
|
doRequest(t, http.MethodDelete, api("/api/v2/remotes/"+name), nil, "")
|
|
}
|
|
|
|
func createVirtual(t *testing.T, jsonBody string) {
|
|
t.Helper()
|
|
resp, body := doRequest(t, http.MethodPost, api("/api/v2/virtuals"), []byte(jsonBody), "application/json")
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create virtual: status %d: %s", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
func deleteVirtual(t *testing.T, name string) {
|
|
t.Helper()
|
|
doRequest(t, http.MethodDelete, api("/api/v2/virtuals/"+name), nil, "")
|
|
}
|
|
|
|
// getEventually retries a GET until it returns 200 or the deadline passes. Used
|
|
// for asynchronously-generated artifacts (e.g. rpm repodata after upload).
|
|
func getEventually(t *testing.T, url string, timeout time.Duration) (*http.Response, []byte) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
var resp *http.Response
|
|
var body []byte
|
|
for {
|
|
resp, body = doRequest(t, http.MethodGet, url, nil, "")
|
|
if resp.StatusCode == http.StatusOK || time.Now().After(deadline) {
|
|
return resp, body
|
|
}
|
|
time.Sleep(250 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// assertWriteGated checks a write is refused with no token and with a token
|
|
// from an unknown key, and that readURL still serves anonymously.
|
|
func assertWriteGated(t *testing.T, method, url string, body []byte, contentType, readURL string) {
|
|
t.Helper()
|
|
for _, authz := range []string{"", "Bearer " + badToken, basicAuth(badToken)} {
|
|
if resp, b := doRequestAuth(t, method, url, body, contentType, authz); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("%s %s with %.12q: status %d, want 401: %s", method, url, authz, resp.StatusCode, b)
|
|
}
|
|
}
|
|
if resp, b := doRequestAuth(t, http.MethodGet, readURL, nil, "", ""); resp.StatusCode != http.StatusOK {
|
|
t.Errorf("anonymous GET %s: status %d: %s", readURL, resp.StatusCode, b)
|
|
}
|
|
}
|