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
25 changes: 20 additions & 5 deletions packages/code-storage-go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ const (

// NewClient creates a Git storage client.
func NewClient(options Options) (*Client, error) {
if strings.TrimSpace(options.Name) == "" || strings.TrimSpace(options.Key) == "" {
return nil, errors.New("git storage requires a name and key")
if strings.TrimSpace(options.Name) == "" {
return nil, errors.New("git storage requires a name")
}
if strings.TrimSpace(options.Key) == "" && strings.TrimSpace(options.Token) == "" {
return nil, errors.New("git storage requires either a key or a token")
}

apiBaseURL := options.APIBaseURL
Expand All @@ -40,15 +43,20 @@ func NewClient(options Options) (*Client, error) {
version = DefaultAPIVersion
}

privateKey, err := parseECPrivateKey([]byte(options.Key))
if err != nil {
return nil, err
var privateKey *ecdsa.PrivateKey
if strings.TrimSpace(options.Key) != "" {
var err error
privateKey, err = parseECPrivateKey([]byte(options.Key))
if err != nil {
return nil, err
}
}

client := &Client{
options: Options{
Name: options.Name,
Key: options.Key,
Token: options.Token,
APIBaseURL: apiBaseURL,
StorageBaseURL: storageBaseURL,
APIVersion: version,
Expand Down Expand Up @@ -447,6 +455,13 @@ func (c *Client) DeleteGitCredential(ctx context.Context, options DeleteGitCrede
}

func (c *Client) generateJWT(repoID string, options RemoteURLOptions) (string, error) {
if strings.TrimSpace(c.options.Token) != "" {
return c.options.Token, nil
}
if c.privateKey == nil {
return "", errors.New("git storage requires a key to generate a JWT")
}

permissions := options.Permissions
if len(permissions) == 0 {
permissions = []Permission{PermissionGitWrite, PermissionGitRead}
Expand Down
58 changes: 54 additions & 4 deletions packages/code-storage-go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,66 @@ import (

func TestNewClientValidation(t *testing.T) {
_, err := NewClient(Options{})
if err == nil || !strings.Contains(err.Error(), "requires a name and key") {
t.Fatalf("expected validation error, got %v", err)
if err == nil || !strings.Contains(err.Error(), "requires a name") {
t.Fatalf("expected validation error for missing name, got %v", err)
}
_, err = NewClient(Options{Name: "", Key: "test"})
if err == nil {
t.Fatalf("expected error for empty name")
}
_, err = NewClient(Options{Name: "test", Key: ""})
if err == nil {
t.Fatalf("expected error for empty key")
if err == nil || !strings.Contains(err.Error(), "requires either a key or a token") {
t.Fatalf("expected error for missing key and token, got %v", err)
}
_, err = NewClient(Options{Name: "test"})
if err == nil || !strings.Contains(err.Error(), "requires either a key or a token") {
t.Fatalf("expected error when neither key nor token provided, got %v", err)
}
}

func TestNewClientWithToken(t *testing.T) {
client, err := NewClient(Options{Name: "acme", Token: "my-pre-minted-jwt"})
if err != nil {
t.Fatalf("expected no error for token-only client, got %v", err)
}
if client == nil {
t.Fatalf("expected non-nil client")
}
}

func TestNewClientWithTokenEmptyKey(t *testing.T) {
client, err := NewClient(Options{Name: "acme", Key: "", Token: "my-pre-minted-jwt"})
if err != nil {
t.Fatalf("expected no error for token with empty key, got %v", err)
}
if client == nil {
t.Fatalf("expected non-nil client")
}
}

func TestTokenSentVerbatim(t *testing.T) {
expectedToken := "my-pre-minted-jwt-token-value"
var receivedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"repo_id":"repo","url":"https://repo.git"}`))
}))
defer server.Close()

client, err := NewClient(Options{Name: "acme", Token: expectedToken, APIBaseURL: server.URL})
if err != nil {
t.Fatalf("client error: %v", err)
}

_, err = client.CreateRepo(nil, CreateRepoOptions{})
if err != nil {
t.Fatalf("create repo error: %v", err)
}

expected := "Bearer " + expectedToken
if receivedAuth != expected {
t.Fatalf("expected Authorization header %q, got %q", expected, receivedAuth)
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/code-storage-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const (
// Options configure the Git storage client.
type Options struct {
Name string
Key string
Key string // required unless Token is set
Token string // pre-minted JWT, used verbatim if set
APIBaseURL string
StorageBaseURL string
APIVersion int
Expand Down
48 changes: 38 additions & 10 deletions packages/code-storage-python/pierre_storage/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,39 @@ def __init__(self, options: GitStorageOptions) -> None:
ValueError: If required options are missing or invalid
"""
# Validate required fields
if not options or "name" not in options or "key" not in options:
if not options or "name" not in options:
raise ValueError(
"GitStorage requires a name and key. Please check your configuration and try again."
"GitStorage requires a name. Please check your configuration and try again."
)

name = options["name"]
key = options["key"]

if name is None or key is None:
if name is None:
raise ValueError(
"GitStorage requires a name and key. Please check your configuration and try again."
"GitStorage requires a name. Please check your configuration and try again."
)

if not isinstance(name, str) or not name.strip():
raise ValueError("GitStorage name must be a non-empty string.")

if not isinstance(key, str) or not key.strip():
raise ValueError("GitStorage key must be a non-empty string.")
has_key = "key" in options and options.get("key") is not None
has_token = "token" in options and options.get("token") is not None

if not has_key and not has_token:
raise ValueError(
"GitStorage requires either a key or a token. "
"Please check your configuration and try again."
)

if has_key:
key = options["key"]
if not isinstance(key, str) or not key.strip():
raise ValueError("GitStorage key must be a non-empty string.")

if has_token:
token = options["token"]
if not isinstance(token, str) or not token.strip():
raise ValueError("GitStorage token must be a non-empty string.")

# Resolve configuration
api_base_url = options.get("api_base_url") or self.get_default_api_base_url(name)
Expand All @@ -72,12 +87,17 @@ def __init__(self, options: GitStorageOptions) -> None:

self.options: GitStorageOptions = {
"name": name,
"key": key,
"api_base_url": api_base_url,
"storage_base_url": storage_base_url,
"api_version": api_version,
}

if has_key:
self.options["key"] = options["key"]

if has_token:
self.options["token"] = options["token"]

if default_ttl:
self.options["default_ttl"] = default_ttl

Expand Down Expand Up @@ -613,8 +633,16 @@ def _generate_jwt(
options: JWT generation options (internal use)

Returns:
Signed JWT token
Signed JWT token or pre-minted token if set
"""
token = self.options.get("token")
if token:
return token

key = self.options.get("key")
if not key:
raise ValueError("GitStorage requires a key to generate a JWT.")

permissions = ["git:write", "git:read"]
ttl: int = 31536000 # 1 year default
ops: Optional[List[str]] = None
Expand All @@ -637,7 +665,7 @@ def _generate_jwt(
ttl = default_ttl

return generate_jwt(
self.options["key"],
key,
self.options["name"],
repo_id,
permissions,
Expand Down
3 changes: 2 additions & 1 deletion packages/code-storage-python/pierre_storage/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ class GitStorageOptions(TypedDict, total=False):
"""Options for GitStorage client."""

name: str # required
key: str # required
key: str # required unless token is set
token: str # pre-minted JWT, used verbatim if set
api_base_url: Optional[str]
storage_base_url: Optional[str]
api_version: Optional[int]
Expand Down
57 changes: 52 additions & 5 deletions packages/code-storage-python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ def test_store_key(self, git_storage_options: dict, test_key: str) -> None:

def test_missing_options(self) -> None:
"""Test error when options are missing."""
with pytest.raises(ValueError, match="GitStorage requires a name and key"):
with pytest.raises(ValueError, match="GitStorage requires a name"):
GitStorage({}) # type: ignore

def test_null_key(self, test_key: str) -> None:
"""Test error when key is null."""
with pytest.raises(ValueError, match="GitStorage requires a name and key"):
"""Test error when key is null and no token."""
with pytest.raises(ValueError, match="GitStorage requires either a key or a token"):
GitStorage({"name": "test", "key": None}) # type: ignore

def test_empty_key(self) -> None:
"""Test error when key is empty."""
"""Test error when key is empty and no token."""
with pytest.raises(ValueError, match="GitStorage key must be a non-empty string"):
GitStorage({"name": "test", "key": ""})

Expand All @@ -46,7 +46,7 @@ def test_empty_name(self, test_key: str) -> None:
GitStorage({"name": "", "key": test_key})

def test_whitespace_key(self) -> None:
"""Test error when key is whitespace."""
"""Test error when key is whitespace and no token."""
with pytest.raises(ValueError, match="GitStorage key must be a non-empty string"):
GitStorage({"name": "test", "key": " "})

Expand All @@ -65,6 +65,53 @@ def test_non_string_name(self, test_key: str) -> None:
with pytest.raises(ValueError, match="GitStorage name must be a non-empty string"):
GitStorage({"name": 123, "key": test_key}) # type: ignore

def test_create_with_token(self) -> None:
"""Test creating GitStorage with token only."""
storage = GitStorage({"name": "test", "token": "my-pre-minted-jwt"})
assert storage is not None
assert isinstance(storage, GitStorage)

def test_empty_token(self) -> None:
"""Test error when token is empty."""
with pytest.raises(ValueError, match="GitStorage token must be a non-empty string"):
GitStorage({"name": "test", "token": ""})

def test_whitespace_token(self) -> None:
"""Test error when token is whitespace."""
with pytest.raises(ValueError, match="GitStorage token must be a non-empty string"):
GitStorage({"name": "test", "token": " "})

def test_missing_key_and_token(self) -> None:
"""Test error when neither key nor token is provided."""
with pytest.raises(ValueError, match="GitStorage requires either a key or a token"):
GitStorage({"name": "test"})

@pytest.mark.asyncio
async def test_token_sent_verbatim(self) -> None:
"""Test that a pre-minted token is sent verbatim in the Authorization header."""
expected_token = "my-pre-minted-jwt-token-value"
storage = GitStorage({
"name": "test-customer",
"token": expected_token,
"api_base_url": "https://api.test.code.storage",
"storage_base_url": "test.code.storage",
})

mock_response = MagicMock()
mock_response.status_code = 200
mock_response.is_success = True
mock_response.json.return_value = {"repo_id": "test-repo", "url": "https://test.git"}

with patch("httpx.AsyncClient") as mock_client:
mock_post = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value.post = mock_post

await storage.create_repo(id="test-repo")

call_kwargs = mock_post.call_args[1]
headers = call_kwargs["headers"]
assert headers["Authorization"] == f"Bearer {expected_token}"

@pytest.mark.asyncio
async def test_create_repo(self, git_storage_options: dict) -> None:
"""Test creating a repository."""
Expand Down
Loading