Skip to content
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()
}