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
102 changes: 96 additions & 6 deletions cmd/lnk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"io"
"os"

"github.com/spf13/cobra"

lnkcmd "github.com/yashiels/linkedin-cli/internal/cmd"
"github.com/yashiels/linkedin-cli/internal/config"
"github.com/yashiels/linkedin-cli/internal/types"
)

// version is set at build time via -ldflags.
Expand All @@ -18,8 +23,7 @@ var version = "dev"
func main() {
root := newRootCmd()
if err := root.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
os.Exit(writeError(root, os.Stderr, err))
}
}

Expand Down Expand Up @@ -50,18 +54,30 @@ Common flags:
--plain Output as plain tab-separated text (pipe-friendly)
--no-color Disable ANSI colour
--quiet / -q Suppress informational output
--verbose Show HTTP request details on stderr
--debug Show full request/response bodies on stderr`,
--verbose Show bounded HTTP method/status diagnostics on stderr
--debug Show bounded HTTP method/status diagnostics on stderr`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if flagConfig != "" {
if err := os.Setenv("LNK_CONFIG", flagConfig); err != nil {
return types.NewError(2, "cannot select configuration")
}
}
if _, err := config.Load(); err != nil {
return types.NewError(2, "configuration could not be loaded")
}
return nil
},
SilenceUsage: true,
SilenceErrors: true,
}

root.SetFlagErrorFunc(func(*cobra.Command, error) error { return types.NewError(2, "invalid command flags") })
pf := root.PersistentFlags()
pf.BoolVar(&flagJSON, "json", false, "Output as JSON")
pf.BoolVar(&flagPlain, "plain", false, "Output as plain tab-separated text")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "Suppress informational output")
pf.BoolVar(&flagVerbose, "verbose", false, "Show HTTP request details on stderr")
pf.BoolVar(&flagDebug, "debug", false, "Show full request/response bodies on stderr")
pf.BoolVar(&flagVerbose, "verbose", false, "Show bounded HTTP method/status diagnostics on stderr")
pf.BoolVar(&flagDebug, "debug", false, "Show bounded HTTP method/status diagnostics on stderr")
pf.BoolVar(&flagNoColor, "no-color", false, "Disable ANSI colour output")
pf.BoolVar(&flagNoInput, "no-input", false, "Disable interactive prompts (fail instead)")
pf.StringVar(&flagConfig, "config", "", "Path to config file (default ~/.config/lnk/config.toml)")
Expand All @@ -76,6 +92,7 @@ Common flags:
root.AddCommand(lnkcmd.NewProfileCmd())
root.AddCommand(lnkcmd.NewAlertsCmd())
root.AddCommand(lnkcmd.NewStatusCmd())
root.AddCommand(lnkcmd.NewSafetyCmd())

// Shell completion.
root.AddCommand(&cobra.Command{
Expand Down Expand Up @@ -121,3 +138,76 @@ Fish:

return root
}

func writeError(root *cobra.Command, w io.Writer, err error) int {
code := types.ExitGeneral
var coded *types.LnkError
reason := types.ReasonUnspecified
if errors.As(err, &coded) {
if coded.Code >= types.ExitGeneral && coded.Code <= types.ExitRateLimit {
code = coded.Code
}
reason = coded.Reason
}
message, reasonName := safeDiagnosticReason(code, reason)
fields := map[string]any{"error": message, "code": code, "reason": reasonName}
if coded != nil {
if coded.Status >= 100 && coded.Status <= 999 {
fields["status"] = coded.Status
}
switch coded.Method {
case "GET", "POST", "DELETE":
fields["method"] = coded.Method
}
switch coded.Operation {
case "read", "graphql", "normalized_read", "post", "delete":
fields["operation"] = coded.Operation
}
}
jsonMode, _ := root.PersistentFlags().GetBool("json")
if jsonMode {
_ = json.NewEncoder(w).Encode(fields)
} else {
fmt.Fprintf(w, "error: %s", message)
for _, key := range []string{"reason", "method", "operation", "status"} {
if value, ok := fields[key]; ok {
fmt.Fprintf(w, " %s=%v", key, value)
}
}
fmt.Fprintln(w)
}
return code
}

func safeDiagnosticReason(code int, reason types.DiagnosticReason) (string, string) {
switch reason {
case types.ReasonHTTPStatus:
return "LinkedIn returned an unsuccessful HTTP status", "http_status"
case types.ReasonRedirect:
return "LinkedIn redirect rejected", "redirect"
case types.ReasonTransport:
return "LinkedIn request failed", "transport"
case types.ReasonResponseRead:
return "reading LinkedIn response failed", "response_read"
case types.ReasonResponseSize:
return "LinkedIn response exceeds size limit", "response_size"
case types.ReasonJSON:
return "LinkedIn returned invalid JSON", "invalid_json"
case types.ReasonGraphQL:
return "LinkedIn returned GraphQL errors", "graphql_errors"
case types.ReasonRefusal:
return "LinkedIn refused request; inspect safety status", "refusal"
}
switch code {
case types.ExitUsage:
return "invalid arguments or configuration", "usage"
case types.ExitAuth:
return "authentication or refusal stop; inspect safety status", "auth_stop"
case types.ExitNetwork:
return "network or safety state failure; inspect safety status", "network_state"
case types.ExitRateLimit:
return "request budget, cooldown or rate limit reached; inspect safety status", "rate_limit"
default:
return "command failed", "general"
}
}
109 changes: 109 additions & 0 deletions cmd/lnk/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"testing"

"github.com/yashiels/linkedin-cli/internal/types"
)

func isolatedHome(t *testing.T) string {
t.Helper()
home, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("LNK_CONFIG", filepath.Join(home, "config.toml"))
return home
}
func TestErrorOutputModes(t *testing.T) {
for _, jsonMode := range []bool{false, true} {
for _, plain := range []bool{false, true} {
for _, verbose := range []bool{false, true} {
for _, debug := range []bool{false, true} {
for _, code := range []int{1, 2, 3, 4, 5} {
t.Run(fmt.Sprintf("%t/%t/%t/%t/%d", jsonMode, plain, verbose, debug, code), func(t *testing.T) {
isolatedHome(t)
root := newRootCmd()
for flag, value := range map[string]bool{"json": jsonMode, "plain": plain, "verbose": verbose, "debug": debug} {
if err := root.PersistentFlags().Set(flag, fmt.Sprint(value)); err != nil {
t.Fatal(err)
}
}
var output bytes.Buffer
err := fmt.Errorf("PERSONAL-SENTINEL https://URL-SENTINEL: %w", types.WrapError(code, "CODED-MESSAGE-SENTINEL https://PRIVATE-SENTINEL", &url.Error{URL: "https://URL-SENTINEL", Err: fmt.Errorf("BODY-SENTINEL")}))
if got := writeError(root, &output, err); got != code {
t.Fatalf("exit %d expected %d", got, code)
}
if strings.Contains(output.String(), "SENTINEL") {
t.Fatal("sentinel leaked")
}
if jsonMode {
var result map[string]any
if err = json.Unmarshal(output.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result["code"] != float64(code) || result["error"] != map[int]string{1: "command failed", 2: "invalid arguments or configuration", 3: "authentication or refusal stop; inspect safety status", 4: "network or safety state failure; inspect safety status", 5: "request budget, cooldown or rate limit reached; inspect safety status"}[code] {
t.Fatalf("wrong error shape: %v", result)
}
}
})
}
}
}
}
}
}
func TestSafetyCommandsAreOfflineAndJSON(t *testing.T) {
home := isolatedHome(t)
for _, args := range [][]string{{"safety", "status", "--json"}, {"--json", "safety", "status"}, {"safety", "resume", "--confirm", "--json"}} {
var output bytes.Buffer
root := newRootCmd()
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var state map[string]any
if err := json.Unmarshal(output.Bytes(), &state); err != nil {
t.Fatal(err)
}
if state["admissions"] != float64(0) || state["budget"] != float64(100) {
t.Fatalf("unexpected local state %v", state)
}
}
if _, err := os.Stat(filepath.Join(home, ".config", "lnk", "credentials.json")); !os.IsNotExist(err) {
t.Fatal("safety touched credentials")
}
root := newRootCmd()
root.SetArgs([]string{"safety", "resume", "--json"})
err := root.Execute()
var output bytes.Buffer
if got := writeError(root, &output, err); got != 2 || !json.Valid(output.Bytes()) {
t.Fatalf("confirmation: exit=%d %s", got, output.String())
}
}
func TestUnsafeConfigAndFlagsExitUsage(t *testing.T) {
home := isolatedHome(t)
path := filepath.Join(home, "unsafe.toml")
if err := os.WriteFile(path, []byte("[safety]\nbudget=101\n"), 0600); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"--config", path, "safety", "status"}, {"--unknown"}} {
root := newRootCmd()
root.SetArgs(args)
err := root.Execute()
var out bytes.Buffer
if got := writeError(root, &out, err); got != 2 {
t.Fatalf("exit=%d error=%v", got, err)
}
}
}
116 changes: 116 additions & 0 deletions cmd/lnk/review_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"

"github.com/yashiels/linkedin-cli/internal/api"
"github.com/yashiels/linkedin-cli/internal/auth"
"github.com/yashiels/linkedin-cli/internal/types"
)

type reviewTransport func(*http.Request) (*http.Response, error)

func (f reviewTransport) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }

func TestAPIStatusDiagnosticsAcrossModes(t *testing.T) {
for _, jsonMode := range []bool{false, true} {
for _, plain := range []bool{false, true} {
for _, verbose := range []bool{false, true} {
for _, debug := range []bool{false, true} {
for _, status := range []int{418, 500, 401, 429, 999, 302} {
for _, method := range []string{"GET", "POST", "PING"} {
t.Run(fmt.Sprintf("%t/%t/%t/%t/%d/%s", jsonMode, plain, verbose, debug, status, method), func(t *testing.T) {
isolatedHome(t)
var logs bytes.Buffer
client := api.New(auth.Credentials{LiAt: "COOKIE-SENTINEL", CSRFToken: "CSRF-SENTINEL"}, api.WithHTTPClient(&http.Client{Transport: reviewTransport(func(*http.Request) (*http.Response, error) {
headers := make(http.Header)
headers.Set("Location", "/authwall?URL-SENTINEL")
return &http.Response{StatusCode: status, Header: headers, Body: io.NopCloser(strings.NewReader("BODY-SENTINEL"))}, nil
})}), api.WithVerbose(verbose), api.WithDebug(debug), api.WithErrWriter(&logs))
var err error
switch method {
case "GET":
_, err = client.Get("/voyager/api/fixture", nil)
case "POST":
_, err = client.Post("/voyager/api/fixture", map[string]string{"private": "REQUEST-SENTINEL"})
case "PING":
_, err = client.Ping()
}
if err == nil {
t.Fatal("unexpected status accepted")
}
root := newRootCmd()
for flag, value := range map[string]bool{"json": jsonMode, "plain": plain, "verbose": verbose, "debug": debug} {
if flagErr := root.PersistentFlags().Set(flag, fmt.Sprint(value)); flagErr != nil {
t.Fatal(flagErr)
}
}
var output bytes.Buffer
got := writeError(root, &output, fmt.Errorf("WRAPPER-SENTINEL: %w", err))
expected := 1
if status == 401 || status == 999 || status == 302 {
expected = 3
} else if status == 429 {
expected = 5
}
if got != expected {
t.Fatalf("code=%d expected=%d", got, expected)
}
if strings.Contains(output.String()+logs.String(), "SENTINEL") {
t.Fatal("private diagnostic leaked")
}
httpMethod := method
operation := "normalized_read"
if method == "POST" {
operation = "post"
} else if method == "PING" {
httpMethod = "GET"
operation = "read"
}
if jsonMode {
var fields map[string]any
if err = json.Unmarshal(output.Bytes(), &fields); err != nil {
t.Fatal(err)
}
if fields["status"] != float64(status) || fields["method"] != httpMethod || fields["operation"] != operation {
t.Fatalf("structured HTTP context lost: %v", fields)
}
} else {
for _, field := range []string{fmt.Sprintf("status=%d", status), "method=" + httpMethod, "operation=" + operation} {
if !strings.Contains(output.String(), field) {
t.Fatalf("safe HTTP context lost: %s", output.String())
}
}
}
})
}
}
}
}
}
}
}

func TestUntrustedDiagnosticMetadataIsFiltered(t *testing.T) {
for _, jsonMode := range []bool{false, true} {
isolatedHome(t)
root := newRootCmd()
if err := root.PersistentFlags().Set("json", fmt.Sprint(jsonMode)); err != nil {
t.Fatal(err)
}
coded := &types.LnkError{Code: 4, Message: "MESSAGE-SENTINEL", Cause: fmt.Errorf("CAUSE-SENTINEL"), Reason: types.DiagnosticReason(255), Method: "METHOD-SENTINEL", Operation: "OPERATION-SENTINEL", Status: 123456789}
var output bytes.Buffer
if got := writeError(root, &output, coded); got != 4 {
t.Fatal("exit code lost")
}
if strings.Contains(output.String(), "SENTINEL") || strings.Contains(output.String(), "123456789") {
t.Fatal("untrusted diagnostic metadata escaped allowlist")
}
}
}
Loading
Loading