Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/go-ossf-slsa3-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

# This workflow lets you compile your Go project using a SLSA3 compliant builder.
# This workflow will generate a so-called "provenance" file describing the steps
# that were performed to generate the final binary.
# The project is an initiative of the OpenSSF (openssf.org) and is developed at
# https://github.com/slsa-framework/slsa-github-generator.
# The provenance file can be verified using https://github.com/slsa-framework/slsa-verifier.
# For more information about SLSA and how it improves the supply-chain, visit slsa.dev.

name: SLSA Go releaser
on:
workflow_dispatch:
release:
types: [created]

permissions: read-all

jobs:
# ========================================================================================================================================
# Prerequesite: Create a .slsa-goreleaser.yml in the root directory of your project.
# See format in https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/go/README.md#configuration-file
#=========================================================================================================================================
build:
permissions:
id-token: write # To sign.
contents: write # To upload release assets.
actions: read # To read workflow path.
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.4.0
with:
go-version: 1.17
# =============================================================================================================
# Optional: For more options, see https://github.com/slsa-framework/slsa-github-generator#golang-projects
# =============================================================================================================

2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
xurl
/xurl
.xurl_test
.DS_Store
__pycache__/
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
.PHONY: build
build:
go build -o xurl
go build -o xurl ./cmd/xurl

.PHONY: install
install:
go install
go install ./cmd/xurl

.PHONY: clean
clean:
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,23 @@ Installs to `~/.local/bin`. If it's not in your PATH, the script will tell you w

### Go
```bash
go install github.com/xdevplatform/xurl@latest
go install github.com/xdevplatform/xurl/cmd/xurl@latest
```

### Use as a Go library

Import by module path in other Go projects:

```go
import "github.com/xdevplatform/xurl"
```

If your consuming project uses a local checkout of this repo, you can use a `replace` directive in your `go.mod` while still importing by the full module path:

```go
require github.com/xdevplatform/xurl v0.0.0

replace github.com/xdevplatform/xurl => ../xurl
```


Expand Down
1 change: 1 addition & 0 deletions api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/config"
Expand Down
5 changes: 3 additions & 2 deletions api/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,12 +354,13 @@ func (m *MediaUploader) WaitForProcessing() (json.RawMessage, error) {
}

state := statusResponse.Data.ProcessingInfo.State
if state == "succeeded" {
switch state {
case "succeeded":
if m.verbose {
fmt.Printf("\033[32mMedia processing complete!\033[0m\n")
}
return response, nil
} else if state == "failed" {
case "failed":
return nil, fmt.Errorf("media processing failed")
}

Expand Down
213 changes: 213 additions & 0 deletions auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,219 @@ func TestOAuth1HeaderWithTokenStore(t *testing.T) {
assert.Contains(t, header, "oauth_consumer_key")
}

// futureExpiry returns a unix timestamp 1 hour in the future.
func futureExpiry() uint64 {
return uint64(time.Now().Add(time.Hour).Unix())
}

// setupMultiAppAuth creates a token store with two apps for multi-app tests.
func setupMultiAppAuth(t *testing.T) (*Auth, *store.TokenStore, string) {
tempDir, err := os.MkdirTemp("", "xurl_multiapp_test")
require.NoError(t, err)

tempFile := filepath.Join(tempDir, ".xurl")
ts := &store.TokenStore{
Apps: make(map[string]*store.App),
DefaultApp: "app-a",
FilePath: tempFile,
}

ts.Apps["app-a"] = &store.App{
ClientID: "id-a",
ClientSecret: "secret-a",
DefaultUser: "alice-a",
OAuth2Tokens: map[string]store.Token{
"alice-a": {
Type: store.OAuth2TokenType,
OAuth2: &store.OAuth2Token{
AccessToken: "oauth2-token-alice-a",
RefreshToken: "refresh-alice-a",
ExpirationTime: futureExpiry(),
},
},
},
OAuth1Token: &store.Token{
Type: store.OAuth1TokenType,
OAuth1: &store.OAuth1Token{
AccessToken: "at-a",
TokenSecret: "ts-a",
ConsumerKey: "ck-a",
ConsumerSecret: "cs-a",
},
},
BearerToken: &store.Token{
Type: store.BearerTokenType,
Bearer: "bearer-a",
},
}

ts.Apps["app-b"] = &store.App{
ClientID: "id-b",
ClientSecret: "secret-b",
DefaultUser: "alice-b",
OAuth2Tokens: map[string]store.Token{
"alice-b": {
Type: store.OAuth2TokenType,
OAuth2: &store.OAuth2Token{
AccessToken: "oauth2-token-alice-b",
RefreshToken: "refresh-alice-b",
ExpirationTime: futureExpiry(),
},
},
},
OAuth1Token: &store.Token{
Type: store.OAuth1TokenType,
OAuth1: &store.OAuth1Token{
AccessToken: "at-b",
TokenSecret: "ts-b",
ConsumerKey: "ck-b",
ConsumerSecret: "cs-b",
},
},
BearerToken: &store.Token{
Type: store.BearerTokenType,
Bearer: "bearer-b",
},
}

a := NewAuth(&config.Config{
ClientID: "id-a",
ClientSecret: "secret-a",
APIBaseURL: "https://api.x.com",
AuthURL: "https://x.com/i/oauth2/authorize",
TokenURL: "https://api.x.com/2/oauth2/token",
RedirectURI: "http://localhost:8080/callback",
InfoURL: "https://api.x.com/2/users/me",
}).WithTokenStore(ts)

return a, ts, tempDir
}

// TC 5.1: WithAppName overwrites non-empty clientID/clientSecret
func TestTC5_1_WithAppNameOverwritesNonEmptyCredentials(t *testing.T) {
a, _, tempDir := setupMultiAppAuth(t)
defer os.RemoveAll(tempDir)

// Auth starts with app-a's non-empty credentials
require.Equal(t, "id-a", a.clientID)
require.Equal(t, "secret-a", a.clientSecret)

// Switch to app-b — must overwrite even though clientID/clientSecret are non-empty
a.WithAppName("app-b")
assert.Equal(t, "id-b", a.clientID, "WithAppName must overwrite non-empty clientID")
assert.Equal(t, "secret-b", a.clientSecret, "WithAppName must overwrite non-empty clientSecret")
}

// TC 5.2: After WithAppName("app-b"), verify a.clientID and a.clientSecret match app-b
func TestTC5_2_ClientCredentialsMatchApp(t *testing.T) {
a, ts, tempDir := setupMultiAppAuth(t)
defer os.RemoveAll(tempDir)

a.WithAppName("app-b")
assert.Equal(t, ts.Apps["app-b"].ClientID, a.clientID)
assert.Equal(t, ts.Apps["app-b"].ClientSecret, a.clientSecret)
assert.Equal(t, "app-b", a.appName)
}

// TC 5.4: app-b has "alice-b" (default_user) and "bob-b" → GetOAuth2Header("") returns alice-b's token
func TestTC5_4_DefaultUserOAuth2(t *testing.T) {
a, ts, tempDir := setupMultiAppAuth(t)
defer os.RemoveAll(tempDir)

// Add "bob-b" to app-b as well
ts.Apps["app-b"].OAuth2Tokens["bob-b"] = store.Token{
Type: store.OAuth2TokenType,
OAuth2: &store.OAuth2Token{
AccessToken: "oauth2-token-bob-b",
RefreshToken: "refresh-bob-b",
ExpirationTime: futureExpiry(),
},
}

a.WithAppName("app-b")
// DefaultUser is "alice-b", so GetOAuth2Header("") should return alice-b's token
header, err := a.GetOAuth2Header("")
require.NoError(t, err)
assert.Equal(t, "Bearer oauth2-token-alice-b", header)
}

// TC 5.5: After WithAppName("app-b"), SaveOAuth2TokenForApp stores in app-b
func TestTC5_5_SaveOAuth2TokenGoesToActiveApp(t *testing.T) {
a, ts, tempDir := setupMultiAppAuth(t)
defer os.RemoveAll(tempDir)

a.WithAppName("app-b")

// Save a new token through the store using a.appName
err := ts.SaveOAuth2TokenForApp(a.appName, "newuser-b", "new-access-b", "new-refresh-b", futureExpiry())
require.NoError(t, err)

// Verify the token is in app-b
tok := ts.GetOAuth2TokenForApp("app-b", "newuser-b")
require.NotNil(t, tok)
assert.Equal(t, "new-access-b", tok.OAuth2.AccessToken)

// Verify app-a is untouched
tokA := ts.GetOAuth2TokenForApp("app-a", "newuser-b")
assert.Nil(t, tokA, "Token should not exist in app-a")
}

// TC 5.6: WithAppName("app-b") → ClearAllForApp → only app-b cleared, default untouched
func TestTC5_6_ClearOnlyActiveApp(t *testing.T) {
a, ts, tempDir := setupMultiAppAuth(t)
defer os.RemoveAll(tempDir)

a.WithAppName("app-b")

// Clear all tokens for app-b
err := ts.ClearAllForApp(a.appName)
require.NoError(t, err)

// app-b should have no tokens
assert.Nil(t, ts.GetBearerTokenForApp("app-b"), "app-b bearer should be cleared")
assert.Nil(t, ts.GetOAuth1TokensForApp("app-b"), "app-b OAuth1 should be cleared")
assert.Empty(t, ts.GetOAuth2UsernamesForApp("app-b"), "app-b OAuth2 tokens should be cleared")

// app-a should be untouched
assert.NotNil(t, ts.GetBearerTokenForApp("app-a"), "app-a bearer must remain")
assert.NotNil(t, ts.GetOAuth1TokensForApp("app-a"), "app-a OAuth1 must remain")
assert.NotEmpty(t, ts.GetOAuth2UsernamesForApp("app-a"), "app-a OAuth2 tokens must remain")
}

// TestAppNameGetter verifies the AppName() getter returns the current override.
func TestAppNameGetter(t *testing.T) {
a, _, tempDir := setupMultiAppAuth(t)
defer os.RemoveAll(tempDir)

// Initial app name comes from config (empty in setupMultiAppAuth)
assert.Empty(t, a.AppName())

a.WithAppName("app-b")
assert.Equal(t, "app-b", a.AppName())
}

// TestBearerTokenSwitchBetweenApps verifies bearer tokens switch with app context.
func TestBearerTokenSwitchBetweenApps(t *testing.T) {
a, _, tempDir := setupMultiAppAuth(t)
defer os.RemoveAll(tempDir)

rounds := []struct {
app string
bearer string
}{
{"app-a", "bearer-a"},
{"app-b", "bearer-b"},
{"app-a", "bearer-a"},
}

for i, r := range rounds {
a.WithAppName(r.app)
header, err := a.GetBearerTokenHeader()
require.NoError(t, err, "round %d: unexpected error", i)
assert.Equal(t, "Bearer "+r.bearer, header, "round %d: wrong bearer for %s", i, r.app)
}
}

func TestGetOAuth2HeaderNoToken(t *testing.T) {
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
Expand Down
File renamed without changes.
27 changes: 27 additions & 0 deletions xurl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package xurl

import (
"github.com/spf13/cobra"

"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/cli"
"github.com/xdevplatform/xurl/config"
)

// NewRootCommand creates the root Cobra command with default configuration.
func NewRootCommand() *cobra.Command {
cfg := config.NewConfig()
a := auth.NewAuth(cfg)

return cli.CreateRootCommand(cfg, a)
}

// CreateRootCommand creates the root Cobra command using caller-provided dependencies.
func CreateRootCommand(cfg *config.Config, a *auth.Auth) *cobra.Command {
return cli.CreateRootCommand(cfg, a)
}

// Execute runs the root command.
func Execute() error {
return NewRootCommand().Execute()
}