From 725707bb9e74a97a24eef5d2450ebf093b269be9 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 2 Oct 2026 13:11:45 +0200 Subject: [PATCH 1/2] fix(jobs): verify application availability conservatively #11 --- README.md | 50 +++- go.mod | 16 +- go.sum | 12 +- internal/api/application.go | 463 +++++++++++++++++++++++++++++++ internal/api/application_html.go | 176 ++++++++++++ internal/api/application_test.go | 438 +++++++++++++++++++++++++++++ internal/api/apply.go | 62 +---- internal/api/client.go | 2 + internal/api/jobdetail.go | 117 ++++++-- internal/api/jobdetail_test.go | 382 +++++++++++++++++++++++++ internal/cmd/apply.go | 150 ++++++---- internal/cmd/apply_test.go | 134 +++++++++ internal/cmd/job.go | 42 ++- internal/cmd/job_test.go | 78 ++++++ internal/types/jobs.go | 43 ++- skill/SKILL.md | 59 +++- 16 files changed, 2053 insertions(+), 171 deletions(-) create mode 100644 internal/api/application.go create mode 100644 internal/api/application_html.go create mode 100644 internal/api/application_test.go create mode 100644 internal/api/jobdetail_test.go create mode 100644 internal/cmd/apply_test.go create mode 100644 internal/cmd/job_test.go diff --git a/README.md b/README.md index bea292b..9ff8b6c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Download from the [Releases](https://github.com/yashiels/linkedin-cli/releases) lnk auth login # paste your li_at cookie lnk search "software engineer" --location "Cape Town" --easy-apply lnk job 4414623196 # view full job details +lnk job 4414623196 --check-apply # verify the current application route lnk apply 4414623196 --dry-run # preview Easy Apply lnk profile johndoe # view any profile lnk feed --limit 10 # recommended jobs @@ -92,20 +93,52 @@ lnk job [flags] | Flag | Description | |---|---| | `--open` | Open the job URL in your default browser | +| `--check-apply` | Verify the current application route; may make bounded anonymous GETs to a supported employer ATS URL | **Example:** ```bash lnk job 4414623196 lnk job 4414623196 --open -lnk job 4414623196 --json +lnk job 4414623196 --check-apply --json ``` +Job details include an `application` object while preserving the existing JSON +fields. Its status is deliberately conservative: + +| Status | Meaning | +|---|---| +| `accepting` | LinkedIn returned `LISTED`, `onsiteApply: true`, and the exact Easy Apply CTA; or supported employer proof was verified | +| `closed` | LinkedIn returns `CLOSED`/`SUSPENDED`, or Workday JSON returns `jobPostingInfo.canApply: false` | +| `unverified` | No supported proof was available, including missing controls, unsupported ATS destinations, network failures, and login or challenge pages | + +`expired: false`, `jobState: LISTED`, an HTTP 200 response, description text that +says “Apply,” or the presence of an employer URL does not mean a job is accepting +applications. `listingUrl` is the LinkedIn listing; `application.applyUrl` is an +employer application URL observed in LinkedIn data. They are never interchangeable. + +`--check-apply` supports narrow proof checks for Lever and Workday. Lever must +return the identified posting's POST application form with an active submission +control inside that form. Workday must return JSON containing a boolean +`jobPostingInfo.canApply`; a normal Workday HTML landing page alone remains +`unverified`. Other ATS destinations remain `unverified` and retain their observed +URL for manual review. Employer checks require HTTPS, use an anonymous client that +does not send LinkedIn credentials, reject non-public destinations and unsafe or +cross-posting redirects, allow at most three redirects, read at most 1 MiB, and +time out after eight seconds. + +Lever text such as “deadline passed” or “no longer accepting” is not structured +closure proof. Without a valid active form, Lever remains `unverified`; agents must +check dated deadlines separately in the current description. + --- ### apply -Apply to a job via Easy Apply. +Apply to a job via Easy Apply. Known-closed jobs are rejected before the Easy +Apply flow. If Easy Apply is unavailable, `lnk` reports an observed employer URL +as unverified or reports that none was observed; it does not generate an employer +URL from the LinkedIn listing. ``` lnk apply [flags] @@ -124,6 +157,19 @@ lnk apply 4414623196 # apply with confirmation prompt lnk apply 4414623196 --confirm # apply without prompt ``` +## Read-only job verification workflow + +For job discovery or a daily scout: + +1. Deduplicate search results by the stable job `id`, not title or URL. +2. Run `lnk job --check-apply --json` for each candidate. +3. Re-read any closing date in the current description and treat a passed or ambiguous deadline as not apply-now, regardless of status. +4. Put only `application.status == "accepting"` jobs in an apply-now shortlist; keep `unverified` jobs in a separate manual-review list. +5. Assess suitability separately and state qualification or experience gaps explicitly. Application proof says nothing about candidate fit. + +This workflow is read-only. Never run `lnk apply`, `lnk saved add/remove`, or +`lnk alerts create/delete` without explicit user consent for that exact write. + --- ### saved diff --git a/go.mod b/go.mod index de8536f..1476fb9 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,17 @@ module github.com/yashiels/linkedin-cli go 1.26.4 require ( - github.com/BurntSushi/toml v1.6.0 // indirect - github.com/fatih/color v1.19.0 // indirect + github.com/BurntSushi/toml v1.6.0 + github.com/fatih/color v1.19.0 + github.com/mattn/go-isatty v0.0.20 + github.com/spf13/cobra v1.10.2 + golang.org/x/net v0.59.0 + golang.org/x/term v0.46.0 +) + +require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index a371bc7..291f644 100644 --- a/go.sum +++ b/go.sum @@ -15,11 +15,11 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/application.go b/internal/api/application.go new file mode 100644 index 0000000..a98b726 --- /dev/null +++ b/internal/api/application.go @@ -0,0 +1,463 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + + "github.com/yashiels/linkedin-cli/internal/types" +) + +const ( + employerCheckTimeout = 8 * time.Second + employerBodyLimit = 1024 * 1024 + employerRedirectLimit = 3 +) + +var nonPublicApplicationNetworks = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("64:ff9b::/96"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001::/23"), + netip.MustParsePrefix("2001:2::/48"), + netip.MustParsePrefix("2001:20::/28"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("2002::/16"), +} + +type employerApplicationChecker struct { + http *http.Client + lookupIP func(context.Context, string) ([]net.IP, error) + now func() time.Time +} + +func newEmployerApplicationChecker() *employerApplicationChecker { + checker := &employerApplicationChecker{ + lookupIP: func(ctx context.Context, host string) ([]net.IP, error) { + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + ips := make([]net.IP, 0, len(addresses)) + for _, address := range addresses { + ips = append(ips, address.IP) + } + return ips, nil + }, + now: time.Now, + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = checker.dialContext + checker.http = &http.Client{Transport: transport, Timeout: employerCheckTimeout} + return checker +} + +func (*Client) CheckApplication(ctx context.Context, detail *types.JobDetail) types.ApplicationAvailability { + current := detail.Application + if current.CheckedAt == "" { + current.CheckedAt = time.Now().UTC().Format(time.RFC3339) + } + if current.Status == types.ApplicationClosed || current.Status == types.ApplicationAccepting { + return current + } + if current.ApplyURL == "" { + return current + } + return newEmployerApplicationChecker().Check(ctx, current.ApplyURL, current.ApplicantTrackingSystem) +} + +func (c *employerApplicationChecker) Check(ctx context.Context, applyURL, ats string) types.ApplicationAvailability { + provider := applicationProvider(applyURL, ats) + result := types.ApplicationAvailability{ + Status: types.ApplicationUnverified, + Source: "employer-verification", + Reason: "No supported employer-specific proof was available.", + CheckedAt: c.now().UTC().Format(time.RFC3339), + ApplyURL: applyURL, + ApplicantTrackingSystem: ats, + } + if provider == "" { + return result + } + + checkCtx, cancel := context.WithTimeout(ctx, employerCheckTimeout) + defer cancel() + currentURL, err := url.Parse(applyURL) + if err != nil { + result.Reason = "The employer application URL is invalid." + return result + } + + httpClient := *c.http + httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + defer httpClient.CloseIdleConnections() + expectedIdentity := applicationIdentity(currentURL, provider) + expectedHost := strings.ToLower(currentURL.Hostname()) + if expectedIdentity == "" { + result.Reason = "The employer application URL does not identify a posting." + return result + } + for redirects := 0; redirects <= employerRedirectLimit; redirects++ { + if err := c.validateURL(checkCtx, currentURL); err != nil { + if redirects > 0 { + result.Reason = "The employer redirect was rejected: " + err.Error() + } else { + result.Reason = "The employer application URL was rejected: " + err.Error() + } + return result + } + if applicationProvider(currentURL.String(), ats) != provider { + result.Reason = "The employer redirect left the verified ATS domain." + return result + } + if strings.ToLower(currentURL.Hostname()) != expectedHost { + result.Reason = "The employer redirect changed the verified ATS host." + return result + } + if applicationIdentity(currentURL, provider) != expectedIdentity { + result.Reason = "The employer redirect identified a different posting." + return result + } + + request, err := http.NewRequestWithContext(checkCtx, http.MethodGet, currentURL.String(), nil) + if err != nil { + result.Reason = "The employer verification request could not be created." + return result + } + request.Header.Set("Accept", "text/html,application/json") + request.Header.Set("User-Agent", "lnk-application-verifier/1") + response, err := httpClient.Do(request) + if err != nil { + result.Reason = "The employer verification request failed: " + err.Error() + return result + } + + if response.StatusCode >= 300 && response.StatusCode < 400 { + _ = response.Body.Close() + location := response.Header.Get("Location") + if location == "" { + result.Reason = "The employer returned a redirect without a destination." + return result + } + nextURL, err := currentURL.Parse(location) + if err != nil { + result.Reason = "The employer returned an invalid redirect." + return result + } + currentURL = nextURL + continue + } + + body, tooLarge, err := readEmployerBody(response.Body) + _ = response.Body.Close() + if err != nil { + result.Reason = "The employer response could not be read." + return result + } + if tooLarge { + result.Reason = "The employer response exceeded the verification size limit." + return result + } + return evaluateEmployerResponseAtURL(result, provider, response.StatusCode, body, currentURL.String()) + } + + result.Reason = "The employer exceeded the redirect limit." + return result +} + +func (c *employerApplicationChecker) validateURL(ctx context.Context, target *url.URL) error { + if target.Scheme != "https" { + return fmt.Errorf("HTTPS is required") + } + if target.User != nil || target.Hostname() == "" { + return fmt.Errorf("invalid host") + } + if port := target.Port(); port != "" && port != "443" { + return fmt.Errorf("non-standard port is not allowed") + } + addresses, err := c.lookupIP(ctx, target.Hostname()) + if err != nil { + return fmt.Errorf("DNS lookup failed") + } + if len(addresses) == 0 { + return fmt.Errorf("DNS returned no addresses") + } + for _, address := range addresses { + if !isPublicApplicationIP(address) { + return fmt.Errorf("destination resolves to a non-public address") + } + } + return nil +} + +func (c *employerApplicationChecker) dialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ips, err := c.lookupIP(ctx, host) + if err != nil { + return nil, err + } + for _, ip := range ips { + if isPublicApplicationIP(ip) { + return (&net.Dialer{Timeout: employerCheckTimeout}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } + } + return nil, fmt.Errorf("destination resolves to a non-public address") +} + +func isPublicApplicationIP(ip net.IP) bool { + address, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() || address.IsUnspecified() { + return false + } + for _, network := range nonPublicApplicationNetworks { + if network.Contains(address) { + return false + } + } + return true +} + +func applicationProvider(rawURL, ats string) string { + target, err := url.Parse(rawURL) + if err != nil { + return "" + } + host := strings.ToLower(target.Hostname()) + path := strings.ToLower(strings.TrimSuffix(target.EscapedPath(), "/")) + switch { + case strings.EqualFold(ats, "Lever") && host == "jobs.lever.co" && strings.HasSuffix(path, "/apply") && applicationIdentity(target, "lever") != "": + return "lever" + case strings.EqualFold(ats, "Workday") && strings.HasSuffix(host, ".myworkdayjobs.com") && applicationIdentity(target, "workday") != "": + return "workday" + default: + return "" + } +} + +func applicationIdentity(target *url.URL, provider string) string { + parts := strings.Split(strings.Trim(target.EscapedPath(), "/"), "/") + for index, part := range parts { + decoded, err := url.PathUnescape(part) + if err == nil { + parts[index] = decoded + } + } + switch provider { + case "lever": + if len(parts) < 3 || !strings.EqualFold(parts[len(parts)-1], "apply") { + return "" + } + return strings.ToLower(parts[len(parts)-2]) + case "workday": + jobSegment := -1 + for index, part := range parts { + if strings.EqualFold(part, "job") { + jobSegment = index + } + } + if jobSegment < 0 || jobSegment == len(parts)-1 { + return "" + } + return strings.ToLower(parts[len(parts)-1]) + default: + return "" + } +} + +func readEmployerBody(reader io.Reader) ([]byte, bool, error) { + body, err := io.ReadAll(io.LimitReader(reader, employerBodyLimit+1)) + if err != nil { + return nil, false, err + } + if len(body) > employerBodyLimit { + return nil, true, nil + } + return body, false, nil +} + +func evaluateEmployerResponse(result types.ApplicationAvailability, provider string, statusCode int, body []byte) types.ApplicationAvailability { + return evaluateEmployerResponseAtURL(result, provider, statusCode, body, result.ApplyURL) +} + +func evaluateEmployerResponseAtURL(result types.ApplicationAvailability, provider string, statusCode int, body []byte, pageURL string) types.ApplicationAvailability { + lower := strings.ToLower(string(body)) + if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || statusCode == http.StatusTooManyRequests || isLoginOrChallengePage(lower) { + result.Reason = "The employer returned a login or challenge page instead of application proof." + return result + } + if statusCode != http.StatusOK { + result.Reason = fmt.Sprintf("The employer returned HTTP %d without application proof.", statusCode) + return result + } + switch provider { + case "lever": + if hasActiveLeverApplicationForm(body, pageURL) { + result.Status = types.ApplicationAccepting + result.Source = "employer-lever" + result.Evidence = "The employer returned a POST Lever application form with an active submission control for this posting." + result.Reason = "The Lever application form is currently available." + return result + } + result.Reason = "Lever did not return its live application form and submit control." + case "workday": + canApply, externalPaths, ok := parseWorkdayCanApply(body) + if ok && workdayIdentityMatches(externalPaths, applicationIdentityFromString(pageURL, "workday")) { + result.Source = "employer-workday" + result.Evidence = fmt.Sprintf("The employer returned jobPostingInfo.canApply=%t in JSON.", canApply) + if canApply { + result.Status = types.ApplicationAccepting + result.Reason = "Workday job JSON explicitly reports that applications are accepted." + } else { + result.Status = types.ApplicationClosed + result.Reason = "Workday job JSON explicitly reports that applications are closed." + } + return result + } + result.Reason = "Workday did not return matching JSON jobPostingInfo.canApply proof." + } + return result +} + +func applicationIdentityFromString(rawURL, provider string) string { + target, err := url.Parse(rawURL) + if err != nil { + return "" + } + return applicationIdentity(target, provider) +} + +func workdayIdentityMatches(paths []string, expected string) bool { + if expected == "" { + return false + } + for _, path := range paths { + target, err := url.Parse(path) + if err != nil || applicationIdentity(target, "workday") != expected { + return false + } + } + return true +} + +func parseWorkdayCanApply(body []byte) (bool, []string, bool) { + decoder := json.NewDecoder(bytes.NewReader(body)) + opening, err := decoder.Token() + if err != nil || opening != json.Delim('{') { + return false, nil, false + } + seenTrue := false + seenFalse := false + seenInfo := false + var paths []string + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return false, nil, false + } + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + return false, nil, false + } + if key != "jobPostingInfo" { + continue + } + infoTrue, infoFalse, infoPaths, ok := parseWorkdayJobPostingInfo(raw) + if !ok { + return false, nil, false + } + seenInfo = true + seenTrue = seenTrue || infoTrue + seenFalse = seenFalse || infoFalse + paths = append(paths, infoPaths...) + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') || !jsonAtEOF(decoder) || !seenInfo || !seenTrue && !seenFalse { + return false, nil, false + } + if seenFalse { + return false, paths, true + } + return true, paths, true +} + +func parseWorkdayJobPostingInfo(raw json.RawMessage) (bool, bool, []string, bool) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + opening, err := decoder.Token() + if err != nil || opening != json.Delim('{') { + return false, false, nil, false + } + seenTrue := false + seenFalse := false + var paths []string + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return false, false, nil, false + } + switch key { + case "canApply": + var value bool + if err := decoder.Decode(&value); err != nil { + return false, false, nil, false + } + seenTrue = seenTrue || value + seenFalse = seenFalse || !value + case "externalPath": + var value string + if err := decoder.Decode(&value); err != nil { + return false, false, nil, false + } + paths = append(paths, value) + default: + var discarded json.RawMessage + if err := decoder.Decode(&discarded); err != nil { + return false, false, nil, false + } + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') || !jsonAtEOF(decoder) { + return false, false, nil, false + } + return seenTrue, seenFalse, paths, true +} + +func jsonAtEOF(decoder *json.Decoder) bool { + var extra interface{} + return decoder.Decode(&extra) == io.EOF +} + +func isLoginOrChallengePage(lower string) bool { + loginTitles := []string{"sign in", "<title>log in", "<title>login", "<title>just a moment", "access denied", "challenge-platform"} + for _, marker := range loginTitles { + if strings.Contains(lower, marker) { + return true + } + } + return false +} diff --git a/internal/api/application_html.go b/internal/api/application_html.go new file mode 100644 index 0000000..890dd9a --- /dev/null +++ b/internal/api/application_html.go @@ -0,0 +1,176 @@ +package api + +import ( + "bytes" + "net/url" + "strings" + + "golang.org/x/net/html" +) + +type leverFormState struct { + activeProviderControl bool + activeSubmitControl bool + submitProxy bool +} + +func hasActiveLeverApplicationForm(body []byte, pageURL string) bool { + document, err := html.Parse(bytes.NewReader(body)) + if err != nil { + return false + } + return findActiveLeverApplicationForm(document, pageURL) +} + +func findActiveLeverApplicationForm(node *html.Node, pageURL string) bool { + if node.Type == html.ElementNode && node.Data == "template" { + return false + } + if node.Type == html.ElementNode && node.Data == "form" && isLeverApplicationForm(node, pageURL) { + return leverFormHasActiveControl(node, pageURL) + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + if findActiveLeverApplicationForm(child, pageURL) { + return true + } + } + return false +} + +func isLeverApplicationForm(form *html.Node, pageURL string) bool { + formID, _ := htmlAttribute(form, "id") + method, _ := htmlAttribute(form, "method") + if formID != "application-form" || !strings.EqualFold(strings.TrimSpace(method), "post") { + return false + } + action, _ := htmlAttribute(form, "action") + return leverTargetMatches(pageURL, action) +} + +func leverFormHasActiveControl(form *html.Node, pageURL string) bool { + state := leverFormState{} + visitLeverFormControls(form, form, pageURL, &state) + return state.activeSubmitControl || state.activeProviderControl && state.submitProxy +} + +func visitLeverFormControls(node, form *html.Node, pageURL string, state *leverFormState) { + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type != html.ElementNode || child.Data != "template" { + if child.Type == html.ElementNode && (child.Data == "button" || child.Data == "input") { + updateLeverFormState(state, child, form, pageURL) + } + visitLeverFormControls(child, form, pageURL, state) + } + } +} + +func updateLeverFormState(state *leverFormState, control, form *html.Node, pageURL string) { + if !leverControlBelongsToForm(control, form) || leverControlDisabled(control, form) || !leverControlTargetMatches(control, form, pageURL) { + return + } + controlType, _ := htmlAttribute(control, "type") + controlType = strings.ToLower(strings.TrimSpace(controlType)) + if controlType == "" && control.Data == "button" { + controlType = "submit" + } + hidden := leverControlHidden(control, controlType) + if controlType == "submit" { + if hidden { + state.submitProxy = true + } else { + state.activeSubmitControl = true + } + } + dataQA, _ := htmlAttribute(control, "data-qa") + if !hidden && control.Data == "button" && controlType == "button" && dataQA == "btn-submit" { + state.activeProviderControl = true + } +} + +func leverControlBelongsToForm(control, form *html.Node) bool { + owner, present := htmlAttribute(control, "form") + if !present { + return true + } + formID, _ := htmlAttribute(form, "id") + return owner == formID +} + +func leverControlTargetMatches(control, form *html.Node, pageURL string) bool { + method, _ := htmlAttribute(form, "method") + if override, present := htmlAttribute(control, "formmethod"); present { + method = override + } + if !strings.EqualFold(strings.TrimSpace(method), "post") { + return false + } + action, _ := htmlAttribute(form, "action") + if override, present := htmlAttribute(control, "formaction"); present { + action = override + } + return leverTargetMatches(pageURL, action) +} + +func leverTargetMatches(pageURL, action string) bool { + if strings.TrimSpace(action) == "" { + return true + } + page, err := url.Parse(pageURL) + if err != nil { + return false + } + target, err := page.Parse(action) + if err != nil || applicationProvider(target.String(), "Lever") != "lever" { + return false + } + return strings.EqualFold(target.Hostname(), page.Hostname()) && applicationIdentity(target, "lever") == applicationIdentity(page, "lever") +} + +func leverControlDisabled(control, form *html.Node) bool { + if htmlBooleanAttribute(control, "disabled") { + return true + } + ariaDisabled, _ := htmlAttribute(control, "aria-disabled") + if strings.EqualFold(strings.TrimSpace(ariaDisabled), "true") { + return true + } + for parent := control.Parent; parent != nil && parent != form; parent = parent.Parent { + if parent.Type == html.ElementNode && parent.Data == "fieldset" && htmlBooleanAttribute(parent, "disabled") { + return true + } + } + return false +} + +func leverControlHidden(control *html.Node, controlType string) bool { + if htmlBooleanAttribute(control, "hidden") || controlType == "hidden" { + return true + } + ariaHidden, _ := htmlAttribute(control, "aria-hidden") + if strings.EqualFold(strings.TrimSpace(ariaHidden), "true") { + return true + } + className, _ := htmlAttribute(control, "class") + for _, value := range strings.Fields(strings.ToLower(className)) { + if value == "hidden" { + return true + } + } + style, _ := htmlAttribute(control, "style") + style = strings.ReplaceAll(strings.ToLower(style), " ", "") + return strings.Contains(style, "display:none") || strings.Contains(style, "visibility:hidden") +} + +func htmlBooleanAttribute(node *html.Node, name string) bool { + _, present := htmlAttribute(node, name) + return present +} + +func htmlAttribute(node *html.Node, name string) (string, bool) { + for _, attribute := range node.Attr { + if attribute.Namespace == "" && attribute.Key == name { + return attribute.Val, true + } + } + return "", false +} diff --git a/internal/api/application_test.go b/internal/api/application_test.go new file mode 100644 index 0000000..8450ed4 --- /dev/null +++ b/internal/api/application_test.go @@ -0,0 +1,438 @@ +package api + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/yashiels/linkedin-cli/internal/types" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestEmployerApplicationVerification(t *testing.T) { + tests := []struct { + name string + url string + ats string + status int + body string + want types.ApplicationStatus + evidence string + }{ + { + name: "Lever live application form", + url: "https://jobs.lever.co/example/00000000-0000-0000-0000-000000000000/apply", + ats: "Lever", + status: http.StatusOK, + body: `<form id="application-form" method="POST"><button id="hcaptchaSubmitBtn" type="submit" class="hidden"></button><button id="btn-submit" type="button" data-qa="btn-submit">Submit application</button></form>`, + want: types.ApplicationAccepting, + evidence: "POST Lever application form", + }, + { + name: "Workday explicit canApply", + url: "https://example.wd3.myworkdayjobs.com/en-US/example/job/example", + ats: "Workday", + status: http.StatusOK, + body: `{"jobPostingInfo":{"canApply":true}}`, + want: types.ApplicationAccepting, + evidence: "canApply=true", + }, + { + name: "Workday explicit closure", + url: "https://example.wd3.myworkdayjobs.com/en-US/example/job/example", + ats: "Workday", + status: http.StatusOK, + body: `{"jobPostingInfo":{"canApply":false}}`, + want: types.ApplicationClosed, + }, + { + name: "Workday landing page without proof", + url: "https://example.wd3.myworkdayjobs.com/en-US/example/job/example", + ats: "Workday", + status: http.StatusOK, + body: `<html><body>Job details</body></html>`, + want: types.ApplicationUnverified, + }, + { + name: "login wall", + url: "https://jobs.lever.co/example/00000000-0000-0000-0000-000000000000/apply", + ats: "Lever", + status: http.StatusOK, + body: `<html><title>Sign in
`, + want: types.ApplicationUnverified, + }, + { + name: "challenge wall", + url: "https://jobs.lever.co/example/00000000-0000-0000-0000-000000000000/apply", + ats: "Lever", + status: http.StatusForbidden, + body: `Just a moment`, + want: types.ApplicationUnverified, + }, + { + name: "free-text deadline is not structured closure proof", + url: "https://jobs.lever.co/example/00000000-0000-0000-0000-000000000000/apply", + ats: "Lever", + status: http.StatusOK, + body: `This job is no longer accepting applications.`, + want: types.ApplicationUnverified, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + checker := testEmployerChecker(t, func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(test.status) + _, _ = writer.Write([]byte(test.body)) + }) + result := checker.Check(context.Background(), test.url, test.ats) + if result.Status != test.want { + t.Fatalf("status = %q, want %q; reason = %q", result.Status, test.want, result.Reason) + } + if test.evidence != "" && !strings.Contains(result.Evidence, test.evidence) { + t.Fatalf("evidence = %q, want substring %q", result.Evidence, test.evidence) + } + if result.CheckedAt == "" { + t.Fatal("checkedAt must be set after an employer request") + } + }) + } +} + +func TestLeverApplicationVerificationIgnoresFreeTextClosureMarkers(t *testing.T) { + tests := []struct { + name string + body string + want types.ApplicationStatus + }{ + { + name: "comment on live form", + body: `
`, + want: types.ApplicationAccepting, + }, + { + name: "script on live form", + body: `
`, + want: types.ApplicationAccepting, + }, + { + name: "description without form", + body: `

The application deadline has passed.

`, + want: types.ApplicationUnverified, + }, + } + base := types.ApplicationAvailability{Status: types.ApplicationUnverified, ApplyURL: "https://jobs.lever.co/example/current/apply"} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := evaluateEmployerResponse(base, "lever", http.StatusOK, []byte(test.body)) + if result.Status != test.want { + t.Fatalf("status = %q, want %q", result.Status, test.want) + } + }) + } +} + +func TestLeverApplicationVerificationRejectsFalsePositiveMarkup(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "disabled submit", + body: `
`, + }, + { + name: "aria disabled submit", + body: `
`, + }, + { + name: "aria hidden submit", + body: `
`, + }, + { + name: "disabled fieldset", + body: `
`, + }, + { + name: "non-submit button", + body: `
`, + }, + { + name: "non-POST form", + body: `
`, + }, + { + name: "markers split across forms", + body: `
`, + }, + { + name: "markers in comment", + body: ``, + }, + { + name: "markers in script", + body: ``, + }, + { + name: "disabled proxy", + body: `
`, + }, + { + name: "different posting action", + body: `
`, + }, + { + name: "raw script false close delimiter", + body: ``, + }, + { + name: "duplicate form attributes", + body: `
`, + }, + { + name: "control owned by other form", + body: `
`, + }, + { + name: "control overrides method", + body: `
`, + }, + { + name: "control overrides posting target", + body: `
`, + }, + } + base := types.ApplicationAvailability{Status: types.ApplicationUnverified, ApplyURL: "https://jobs.lever.co/example/current/apply"} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := evaluateEmployerResponse(base, "lever", http.StatusOK, []byte(test.body)) + if result.Status != types.ApplicationUnverified { + t.Fatalf("status = %q, want %q", result.Status, types.ApplicationUnverified) + } + }) + } +} + +func TestWorkdayApplicationVerificationRequiresExactJSONPath(t *testing.T) { + tests := []struct { + name string + body string + want types.ApplicationStatus + }{ + {name: "HTML script token", body: ``, want: types.ApplicationUnverified}, + {name: "unrelated JSON token", body: `{"other":{"canApply":true}}`, want: types.ApplicationUnverified}, + {name: "closure wins duplicate flag", body: `{"jobPostingInfo":{"canApply":true,"canApply":false}}`, want: types.ApplicationClosed}, + {name: "different posting identity", body: `{"jobPostingInfo":{"canApply":true,"externalPath":"/en-US/example/job/different"}}`, want: types.ApplicationUnverified}, + } + base := types.ApplicationAvailability{Status: types.ApplicationUnverified, ApplyURL: "https://example.wd3.myworkdayjobs.com/en-US/example/job/current"} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := evaluateEmployerResponse(base, "workday", http.StatusOK, []byte(test.body)) + if result.Status != test.want { + t.Fatalf("status = %q, want %q", result.Status, test.want) + } + }) + } +} + +func TestEmployerApplicationVerificationNetworkErrorIsUnverified(t *testing.T) { + checker := &employerApplicationChecker{ + http: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("network unavailable") + })}, + lookupIP: publicTestLookup, + now: fixedTestTime, + } + result := checker.Check(context.Background(), "https://jobs.lever.co/example/00000000-0000-0000-0000-000000000000/apply", "Lever") + if result.Status != types.ApplicationUnverified { + t.Fatalf("status = %q, want %q", result.Status, types.ApplicationUnverified) + } +} + +func TestEmployerApplicationVerificationRejectsUnsafeDestinations(t *testing.T) { + tests := []struct { + name string + url string + ips []net.IP + }{ + {name: "HTTP", url: "http://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("203.0.113.10")}}, + {name: "loopback", url: "https://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("127.0.0.1")}}, + {name: "private", url: "https://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("10.0.0.1")}}, + {name: "link local", url: "https://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("169.254.1.1")}}, + {name: "shared address space", url: "https://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("100.64.1.1")}}, + {name: "documentation range", url: "https://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("203.0.113.10")}}, + {name: "benchmark range", url: "https://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("198.18.0.1")}}, + {name: "this network", url: "https://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("0.1.2.3")}}, + {name: "NAT64", url: "https://jobs.lever.co/example/id/apply", ips: []net.IP{net.ParseIP("64:ff9b::0808:0808")}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + called := false + checker := &employerApplicationChecker{ + http: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + called = true + return nil, errors.New("must not request unsafe URL") + })}, + lookupIP: publicTestLookup, + now: fixedTestTime, + } + checker.lookupIP = func(context.Context, string) ([]net.IP, error) { return test.ips, nil } + result := checker.Check(context.Background(), test.url, "Lever") + if result.Status != types.ApplicationUnverified { + t.Fatalf("status = %q, want %q", result.Status, types.ApplicationUnverified) + } + if called { + t.Fatal("unsafe destination was requested") + } + }) + } +} + +func TestEmployerApplicationVerificationRejectsDifferentPostingRedirect(t *testing.T) { + checker := testEmployerChecker(t, func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/example/current/apply" { + writer.Header().Set("Location", "https://jobs.lever.co/example/different/apply") + writer.WriteHeader(http.StatusFound) + return + } + _, _ = writer.Write([]byte(`
`)) + }) + result := checker.Check(context.Background(), "https://jobs.lever.co/example/current/apply", "Lever") + if result.Status != types.ApplicationUnverified { + t.Fatalf("status = %q, want %q", result.Status, types.ApplicationUnverified) + } +} + +func TestEmployerApplicationVerificationRejectsDifferentWorkdayHost(t *testing.T) { + checker := testEmployerChecker(t, func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Location", "https://other.wd3.myworkdayjobs.com/en-US/example/job/current") + writer.WriteHeader(http.StatusFound) + }) + result := checker.Check(context.Background(), "https://example.wd3.myworkdayjobs.com/en-US/example/job/current", "Workday") + if result.Status != types.ApplicationUnverified { + t.Fatalf("status = %q, want %q", result.Status, types.ApplicationUnverified) + } +} + +type closeTrackingTransport struct { + closed bool +} + +func (transport *closeTrackingTransport) RoundTrip(request *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + _, _ = recorder.WriteString(`
`) + return recorder.Result(), nil +} + +func (transport *closeTrackingTransport) CloseIdleConnections() { + transport.closed = true +} + +func TestEmployerApplicationVerificationClosesIdleConnections(t *testing.T) { + transport := &closeTrackingTransport{} + checker := &employerApplicationChecker{ + http: &http.Client{Transport: transport}, + lookupIP: publicTestLookup, + now: fixedTestTime, + } + checker.Check(context.Background(), "https://jobs.lever.co/example/current/apply", "Lever") + if !transport.closed { + t.Fatal("idle connections were not closed") + } +} + +func TestEmployerApplicationVerificationChecksRedirectAndLeaksNoCredentials(t *testing.T) { + var mu sync.Mutex + var requests []*http.Request + checker := testEmployerChecker(t, func(writer http.ResponseWriter, request *http.Request) { + mu.Lock() + requests = append(requests, request.Clone(request.Context())) + mu.Unlock() + if request.URL.Query().Get("redirected") == "" { + writer.Header().Set("Location", "https://jobs.lever.co/example/id/apply?redirected=1") + writer.WriteHeader(http.StatusFound) + return + } + _, _ = writer.Write([]byte(`
`)) + }) + result := checker.Check(context.Background(), "https://jobs.lever.co/example/id/apply", "Lever") + if result.Status != types.ApplicationAccepting { + t.Fatalf("safe redirect status = %q, want %q", result.Status, types.ApplicationAccepting) + } + if len(requests) != 2 { + t.Fatalf("request count = %d, want 2", len(requests)) + } + for _, request := range requests { + for _, header := range []string{"Authorization", "Cookie", "Csrf-Token"} { + if request.Header.Get(header) != "" { + t.Fatalf("%s leaked to employer request", header) + } + } + } + + checker = testEmployerChecker(t, func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Location", "https://127.0.0.1/form") + writer.WriteHeader(http.StatusFound) + }) + checker.lookupIP = func(ctx context.Context, host string) ([]net.IP, error) { + if host == "127.0.0.1" { + return []net.IP{net.ParseIP(host)}, nil + } + return publicTestLookup(ctx, host) + } + result = checker.Check(context.Background(), "https://jobs.lever.co/example/id/apply", "Lever") + if result.Status != types.ApplicationUnverified || !strings.Contains(result.Reason, "redirect") { + t.Fatalf("private redirect result = %#v", result) + } +} + +func TestEmployerApplicationVerificationDoesNotRequestUnsupportedATS(t *testing.T) { + called := false + checker := &employerApplicationChecker{ + http: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + called = true + return nil, errors.New("unexpected request") + })}, + lookupIP: publicTestLookup, + now: fixedTestTime, + } + result := checker.Check(context.Background(), "https://careers.example.com/jobs/123", "ExampleATS") + if result.Status != types.ApplicationUnverified { + t.Fatalf("status = %q, want %q", result.Status, types.ApplicationUnverified) + } + if called { + t.Fatal("unsupported ATS was requested") + } +} + +func testEmployerChecker(t *testing.T, handler http.HandlerFunc) *employerApplicationChecker { + t.Helper() + return &employerApplicationChecker{ + http: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder.Result(), nil + })}, + lookupIP: publicTestLookup, + now: fixedTestTime, + } +} + +func publicTestLookup(context.Context, string) ([]net.IP, error) { + return []net.IP{net.ParseIP("8.8.8.8")}, nil +} + +func fixedTestTime() time.Time { + return time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) +} diff --git a/internal/api/apply.go b/internal/api/apply.go index af3e385..caf6986 100644 --- a/internal/api/apply.go +++ b/internal/api/apply.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/url" + "strings" ) const ( @@ -110,16 +111,8 @@ func parseEasyApplyCheck(raw json.RawMessage) (*EasyApplyStatus, error) { return status, nil } - // Available: onsiteApply flag, applyCtaText presence, or $type field. - if boolVal(nav(appDetail, "onsiteApply")) { - status.Available = true - } - if strPath(appDetail, "applyCtaText", "text") != "" { - status.Available = true - } - if strPath(appDetail, "$type") != "" { - status.Available = true - } + cta := strings.Join(strings.Fields(strings.ToLower(strPath(appDetail, "applyCtaText", "text"))), " ") + status.Available = boolVal(nav(appDetail, "onsiteApply")) && cta == "easy apply" // Prefill data (may not be present for all jobs / account states). if name := strPath(appDetail, "applicantName"); name != "" { @@ -141,11 +134,6 @@ func parseEasyApplyCheck(raw json.RawMessage) (*EasyApplyStatus, error) { status.Resume = strPath(appDetail, "resumeDocumentName") } - // If any prefill data exists, confirm Easy Apply is available. - if status.Name != "" || status.Email != "" { - status.Available = true - } - // Store the raw form for submission. status.RawForm = raw @@ -186,50 +174,6 @@ func (c *Client) SubmitEasyApply(jobID string, status *EasyApplyStatus) error { return nil } -// ExternalApplyURL returns the external application URL for a job that -// does not support Easy Apply. -func ExternalApplyURL(jobID string) string { - bareID := jobID - if u, err := parseJobURN(jobID); err == nil { - bareID = u - } - return "https://www.linkedin.com/jobs/view/" + url.PathEscape(bareID) -} - -// parseJobURN returns the bare numeric ID from a job URN or the original -// string if it's already a bare ID. -func parseJobURN(urn string) (string, error) { - if !isURN(urn) { - return urn, nil - } - u, err := parseGenericURN(urn) - if err != nil { - return urn, err - } - return u, nil -} - -func isURN(s string) bool { - return len(s) > 4 && s[:4] == "urn:" -} - -func parseGenericURN(urn string) (string, error) { - // urn:li:fsd_jobPosting:1234567890 - parts := make([]string, 0, 4) - start := 0 - for i, ch := range urn { - if ch == ':' { - parts = append(parts, urn[start:i]) - start = i + 1 - } - } - parts = append(parts, urn[start:]) - if len(parts) < 4 { - return "", fmt.Errorf("invalid URN: %s", urn) - } - return parts[len(parts)-1], nil -} - // SaveJob saves a job to the user's saved jobs collection. // // Endpoint: POST /voyager/api/voyagerJobsDashJobCollectionSubscriptions diff --git a/internal/api/client.go b/internal/api/client.go index b12274f..7b4d575 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -66,6 +66,8 @@ func WithDebug(v bool) Option { return func(c *Client) { c.debug = v } } // WithErrWriter sets the writer used for diagnostic output (default os.Stderr). func WithErrWriter(w io.Writer) Option { return func(c *Client) { c.errOut = w } } +func WithHTTPClient(client *http.Client) Option { return func(c *Client) { c.http = client } } + // New creates a Client using the provided credentials. func New(creds auth.Credentials, opts ...Option) *Client { c := &Client{ diff --git a/internal/api/jobdetail.go b/internal/api/jobdetail.go index 4736956..495b236 100644 --- a/internal/api/jobdetail.go +++ b/internal/api/jobdetail.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/yashiels/linkedin-cli/internal/types" ) @@ -34,7 +35,12 @@ func (c *Client) GetJobDetail(jobID string) (*types.JobDetail, error) { return nil, fmt.Errorf("job detail: %w", err) } - return parseJobDetail(raw, jobID) + detail, err := parseJobDetail(raw, jobID) + if err != nil { + return nil, err + } + detail.Application.CheckedAt = time.Now().UTC().Format(time.RFC3339) + return detail, nil } // normaliseJobURN converts a bare numeric ID to a LinkedIn job URN. @@ -110,7 +116,14 @@ func parseJobDetail(raw json.RawMessage, originalID string) (*types.JobDetail, e return nil, fmt.Errorf("job detail: cannot decode response: %w", err) } - detail := &types.JobDetail{} + detail := &types.JobDetail{ + Application: types.ApplicationAvailability{ + Status: types.ApplicationUnverified, + Source: "linkedin-control", + Reason: "LinkedIn returned no sufficient application-control evidence.", + }, + } + observations := applicationObservations{} // Determine the bare numeric ID for URLs etc. bareID := originalID @@ -150,7 +163,7 @@ func parseJobDetail(raw json.RawMessage, originalID string) (*types.JobDetail, e sections := arr(nav(elem, "jobPostingDetailSection")) for _, section := range sections { if topCard := nav(section, "topCardV2"); topCard != nil { - parseTopCard(detail, topCard) + parseTopCard(detail, topCard, &observations) } if jobDesc := nav(section, "jobDescription"); jobDesc != nil { parseDescriptionCard(detail, jobDesc) @@ -158,19 +171,29 @@ func parseJobDetail(raw json.RawMessage, originalID string) (*types.JobDetail, e } // Legacy / fallback: sections without the jobPostingDetailSection wrapper. if topCard := nav(elem, "topCardV2"); topCard != nil { - parseTopCard(detail, topCard) + parseTopCard(detail, topCard, &observations) } if jobDesc := nav(elem, "jobDescription"); jobDesc != nil { parseDescriptionCard(detail, jobDesc) } } + applyApplicationObservations(detail, observations) return detail, nil } +type applicationObservations struct { + positive bool + contradictory bool + listed bool + closedState string + applyURL string + ats string +} + // parseTopCard extracts header information from a topCardV2 section object. // The argument is the topCardV2 value — not the outer section wrapper. -func parseTopCard(d *types.JobDetail, topCard interface{}) { +func parseTopCard(d *types.JobDetail, topCard interface{}, observations *applicationObservations) { // Current Voyager structure: topCardV2.jobPostingCard contains most fields. jpc := nav(topCard, "jobPostingCard") if jpc == nil { @@ -226,23 +249,83 @@ func parseTopCard(d *types.JobDetail, topCard interface{}) { } } - // Easy Apply: onsiteApply flag or "Easy Apply" CTA text. - if boolVal(nav(jpc, "primaryActionV2", "applyJobAction", "applyJobActionResolutionResult", "onsiteApply")) { - d.EasyApply = true - } - if !d.EasyApply { - cta := strPath(jpc, "primaryActionV2", "applyJobAction", "applyJobActionResolutionResult", "applyCtaText", "text") - if strings.Contains(strings.ToLower(cta), "easy apply") { - d.EasyApply = true - } - } + observeApplicationAvailability(observations, jpc) // Salary, employment type, seniority level from job insights. parseSalaryFromInsights(d, jpc) +} - // Job expired / closed. - if str(nav(jpc, "jobPosting", "jobState")) == "CLOSED" { +func observeApplicationAvailability(observations *applicationObservations, jpc interface{}) { + jobState := strings.ToUpper(str(nav(jpc, "jobPosting", "jobState"))) + if jobState == "LISTED" { + observations.listed = true + } + if jobState == "CLOSED" || jobState == "SUSPENDED" { + if observations.closedState == "" || jobState == "CLOSED" { + observations.closedState = jobState + } + } + resolution := nav(jpc, "primaryActionV2", "applyJobAction", "applyJobActionResolutionResult") + if resolution == nil { + return + } + ats := strPath(resolution, "applicantTrackingSystemName") + applyURL := strPath(resolution, "companyApplyUrl") + if applyURL != "" && (observations.applyURL == "" || applyURL < observations.applyURL) { + observations.applyURL = applyURL + observations.ats = ats + } + cta := strings.Join(strings.Fields(strings.ToLower(strPath(resolution, "applyCtaText", "text"))), " ") + onsiteApply, onsitePresent := nav(resolution, "onsiteApply").(bool) + positive := jobState == "LISTED" && onsitePresent && onsiteApply && cta == "easy apply" + if positive { + observations.positive = true + return + } + if onsitePresent || cta != "" || applyURL != "" || ats != "" { + observations.contradictory = true + } +} + +func applyApplicationObservations(d *types.JobDetail, observations applicationObservations) { + d.Application.ApplyURL = observations.applyURL + d.Application.ApplicantTrackingSystem = observations.ats + d.Application.Source = "linkedin-control" + if observations.closedState != "" { d.Expired = true + d.EasyApply = false + d.Application.Status = types.ApplicationClosed + d.Application.Source = "linkedin-job-state" + d.Application.Evidence = "LinkedIn returned jobState=" + observations.closedState + "." + d.Application.Reason = "LinkedIn reports that this listing is not accepting applications." + return + } + if observations.positive && !observations.contradictory { + d.EasyApply = true + d.Application.Status = types.ApplicationAccepting + d.Application.Evidence = "LinkedIn returned jobState=LISTED, onsiteApply=true, and the Easy Apply CTA." + d.Application.Reason = "LinkedIn returned consistent current Easy Apply metadata." + return + } + d.EasyApply = false + d.Application.Status = types.ApplicationUnverified + if observations.positive && observations.contradictory { + d.Application.Evidence = "LinkedIn returned contradictory application-control metadata." + d.Application.Reason = "Application availability could not be verified consistently." + return + } + if observations.applyURL != "" { + d.Application.Evidence = "LinkedIn returned an employer application URL." + d.Application.Reason = "The employer application form has not been verified." + return + } + if observations.contradictory { + d.Application.Evidence = "LinkedIn returned application metadata without sufficient active-state and CTA proof." + d.Application.Reason = "Easy Apply availability could not be verified." + return + } + if observations.listed { + d.Application.Evidence = "LinkedIn returned jobState=LISTED without sufficient application-control evidence." } } diff --git a/internal/api/jobdetail_test.go b/internal/api/jobdetail_test.go new file mode 100644 index 0000000..6570381 --- /dev/null +++ b/internal/api/jobdetail_test.go @@ -0,0 +1,382 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/yashiels/linkedin-cli/internal/types" +) + +func TestParseJobDetailApplicationAvailability(t *testing.T) { + tests := []struct { + name string + state string + description string + resolution map[string]interface{} + status types.ApplicationStatus + expired bool + applyURL string + }{ + { + name: "explicit closure wins over enabled control", + state: "CLOSED", + resolution: map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + status: types.ApplicationClosed, + expired: true, + }, + { + name: "suspended listing is closed", + state: "SUSPENDED", + status: types.ApplicationClosed, + expired: true, + }, + { + name: "missing state and control is unverified", + status: types.ApplicationUnverified, + }, + { + name: "description apply text is not evidence", + state: "LISTED", + description: "Apply today using the instructions below.", + status: types.ApplicationUnverified, + }, + { + name: "disabled control is not accepting", + state: "LISTED", + resolution: map[string]interface{}{ + "onsiteApply": false, + "applyCtaText": map[string]interface{}{"text": "Apply"}, + }, + status: types.ApplicationUnverified, + }, + { + name: "listed LinkedIn Easy Apply metadata is accepting", + state: "LISTED", + resolution: map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + status: types.ApplicationAccepting, + }, + { + name: "unknown state with method metadata is unverified", + resolution: map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + status: types.ApplicationUnverified, + }, + { + name: "negative CTA is unverified", + state: "LISTED", + resolution: map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Apply unavailable"}, + }, + status: types.ApplicationUnverified, + }, + { + name: "observed employer URL remains unverified", + state: "LISTED", + resolution: map[string]interface{}{ + "applicantTrackingSystemName": "Lever", + "onsiteApply": false, + "applyCtaText": map[string]interface{}{"text": "Apply"}, + "companyApplyUrl": "https://jobs.lever.co/example/00000000-0000-0000-0000-000000000000/apply", + }, + status: types.ApplicationUnverified, + applyURL: "https://jobs.lever.co/example/00000000-0000-0000-0000-000000000000/apply", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + detail, err := parseJobDetail(jobDetailFixture(t, test.state, test.description, test.resolution), "1234567890") + if err != nil { + t.Fatal(err) + } + if detail.Application.Status != test.status { + t.Fatalf("status = %q, want %q", detail.Application.Status, test.status) + } + if detail.Expired != test.expired { + t.Fatalf("expired = %t, want %t", detail.Expired, test.expired) + } + if detail.Application.ApplyURL != test.applyURL { + t.Fatalf("apply URL = %q, want %q", detail.Application.ApplyURL, test.applyURL) + } + if detail.Application.ApplyURL != "" && detail.Application.ApplyURL == detail.ListingURL { + t.Fatal("apply URL must remain distinct from listing URL") + } + }) + } +} + +func TestParseJobDetailClosureIsStickyAcrossTopCards(t *testing.T) { + tests := []struct { + name string + states []string + }{ + {name: "closed then listed", states: []string{"CLOSED", "LISTED"}}, + {name: "listed then suspended", states: []string{"LISTED", "SUSPENDED"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sections := make([]interface{}, 0, len(test.states)) + for _, state := range test.states { + sections = append(sections, map[string]interface{}{ + "topCardV2": map[string]interface{}{ + "jobPostingCard": map[string]interface{}{ + "jobPosting": map[string]interface{}{"jobState": state}, + "primaryActionV2": map[string]interface{}{ + "applyJobAction": map[string]interface{}{ + "applyJobActionResolutionResult": map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + }, + }, + }, + }, + }) + } + raw, err := json.Marshal(map[string]interface{}{ + "data": map[string]interface{}{ + "jobsDashJobPostingDetailSectionsByCardSectionTypes": map[string]interface{}{ + "elements": []interface{}{map[string]interface{}{"jobPostingDetailSection": sections}}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + detail, err := parseJobDetail(raw, "1234567890") + if err != nil { + t.Fatal(err) + } + if detail.Application.Status != types.ApplicationClosed || !detail.Expired || detail.EasyApply { + t.Fatalf("detail = %#v", detail) + } + }) + } +} + +func TestParseJobDetailContradictoryTopCardsAreUnverified(t *testing.T) { + tests := []struct { + name string + reversed bool + missingActiveState bool + wantURL string + }{ + {name: "onsite then offsite", wantURL: "https://careers.example.com/jobs/current"}, + {name: "offsite then onsite", reversed: true, wantURL: "https://careers.example.com/jobs/current"}, + {name: "onsite then unknown state", missingActiveState: true}, + {name: "unknown state then onsite", reversed: true, missingActiveState: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + onsite := map[string]interface{}{ + "jobPosting": map[string]interface{}{"jobState": "LISTED"}, + "primaryActionV2": map[string]interface{}{ + "applyJobAction": map[string]interface{}{ + "applyJobActionResolutionResult": map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + }, + }, + } + offsite := map[string]interface{}{ + "jobPosting": map[string]interface{}{"jobState": "LISTED"}, + "primaryActionV2": map[string]interface{}{ + "applyJobAction": map[string]interface{}{ + "applyJobActionResolutionResult": map[string]interface{}{ + "onsiteApply": false, + "applyCtaText": map[string]interface{}{"text": "Apply"}, + "applicantTrackingSystemName": "ExampleATS", + "companyApplyUrl": "https://careers.example.com/jobs/current", + }, + }, + }, + } + if test.missingActiveState { + offsite = map[string]interface{}{ + "jobPosting": map[string]interface{}{}, + "primaryActionV2": map[string]interface{}{ + "applyJobAction": map[string]interface{}{ + "applyJobActionResolutionResult": map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + }, + }, + } + } + cards := []interface{}{onsite, offsite} + if test.reversed { + cards[0], cards[1] = cards[1], cards[0] + } + sections := make([]interface{}, 0, len(cards)) + for _, card := range cards { + sections = append(sections, map[string]interface{}{"topCardV2": map[string]interface{}{"jobPostingCard": card}}) + } + raw, err := json.Marshal(map[string]interface{}{ + "data": map[string]interface{}{ + "jobsDashJobPostingDetailSectionsByCardSectionTypes": map[string]interface{}{ + "elements": []interface{}{map[string]interface{}{"jobPostingDetailSection": sections}}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + detail, err := parseJobDetail(raw, "1234567890") + if err != nil { + t.Fatal(err) + } + if detail.Application.Status != types.ApplicationUnverified || detail.EasyApply { + t.Fatalf("detail = %#v", detail) + } + if detail.Application.ApplyURL != test.wantURL { + t.Fatalf("apply URL = %q", detail.Application.ApplyURL) + } + }) + } +} + +func TestParseJobDetailMissingOptionalControlDoesNotContradictPositiveEvidence(t *testing.T) { + raw, err := json.Marshal(map[string]interface{}{ + "data": map[string]interface{}{ + "jobsDashJobPostingDetailSectionsByCardSectionTypes": map[string]interface{}{ + "elements": []interface{}{map[string]interface{}{ + "jobPostingDetailSection": []interface{}{ + map[string]interface{}{"topCardV2": map[string]interface{}{"jobPostingCard": map[string]interface{}{ + "jobPosting": map[string]interface{}{"jobState": "LISTED"}, + "primaryActionV2": map[string]interface{}{"applyJobAction": map[string]interface{}{ + "applyJobActionResolutionResult": map[string]interface{}{ + "onsiteApply": true, "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + }}, + }}}, + map[string]interface{}{"topCardV2": map[string]interface{}{"jobPostingCard": map[string]interface{}{ + "jobPosting": map[string]interface{}{"jobState": "LISTED"}, + }}}, + }, + }}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + detail, err := parseJobDetail(raw, "1234567890") + if err != nil { + t.Fatal(err) + } + if detail.Application.Status != types.ApplicationAccepting || !detail.EasyApply { + t.Fatalf("detail = %#v", detail) + } +} + +func TestParseEasyApplyCheckRequiresEnabledOnsiteControl(t *testing.T) { + tests := []struct { + name string + detail map[string]interface{} + available bool + }{ + { + name: "enabled onsite control", + detail: map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + available: true, + }, + { + name: "CTA without enabled control", + detail: map[string]interface{}{ + "onsiteApply": false, + "applyCtaText": map[string]interface{}{"text": "Easy Apply"}, + }, + }, + { + name: "negative CTA", + detail: map[string]interface{}{ + "onsiteApply": true, + "applyCtaText": map[string]interface{}{"text": "Apply unavailable"}, + }, + }, + { + name: "prefill data without enabled control", + detail: map[string]interface{}{ + "emailAddress": "candidate@example.com", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw, err := json.Marshal(map[string]interface{}{ + "data": map[string]interface{}{ + "jobsDashOnsiteApplyApplicationByJobPosting": map[string]interface{}{ + "elements": []interface{}{map[string]interface{}{"jobSeekerApplicationDetail": test.detail}}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + status, err := parseEasyApplyCheck(raw) + if err != nil { + t.Fatal(err) + } + if status.Available != test.available { + t.Fatalf("available = %t, want %t", status.Available, test.available) + } + }) + } +} + +func jobDetailFixture(t *testing.T, state, description string, resolution map[string]interface{}) json.RawMessage { + t.Helper() + jobPosting := map[string]interface{}{} + if state != "" { + jobPosting["jobState"] = state + } + card := map[string]interface{}{ + "jobPostingTitle": "Example role", + "jobPosting": jobPosting, + } + if resolution != nil { + card["primaryActionV2"] = map[string]interface{}{ + "applyJobAction": map[string]interface{}{ + "applyJobActionResolutionResult": resolution, + }, + } + } + sections := []interface{}{ + map[string]interface{}{"topCardV2": map[string]interface{}{"jobPostingCard": card}}, + } + if description != "" { + sections = append(sections, map[string]interface{}{ + "jobDescription": map[string]interface{}{ + "jobPosting": map[string]interface{}{ + "description": map[string]interface{}{"text": description}, + }, + }, + }) + } + raw, err := json.Marshal(map[string]interface{}{ + "data": map[string]interface{}{ + "jobsDashJobPostingDetailSectionsByCardSectionTypes": map[string]interface{}{ + "elements": []interface{}{map[string]interface{}{"jobPostingDetailSection": sections}}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + return raw +} diff --git a/internal/cmd/apply.go b/internal/cmd/apply.go index 97ae657..c6788a2 100644 --- a/internal/cmd/apply.go +++ b/internal/cmd/apply.go @@ -3,6 +3,7 @@ package cmd import ( "bufio" "fmt" + "io" "os" "strings" @@ -33,8 +34,8 @@ This command: 4. Asks for confirmation (unless --confirm or --no-input is set) 5. Submits the application -If Easy Apply is not available, the external application URL is printed -instead and no application is submitted.`, +If Easy Apply is not available, an observed employer application URL is shown +as unverified. The LinkedIn listing URL is never presented as an employer URL.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runApply(cmd, args[0], runApplyOpts{ @@ -62,6 +63,8 @@ type runApplyOpts struct { noInput, dryRun, confirm bool } +var newApplyClient = api.New + func runApply(cmd *cobra.Command, jobID string, opts runApplyOpts) error { store, err := auth.Default() if err != nil { @@ -94,7 +97,7 @@ func runApply(cmd *cobra.Command, jobID string, opts runApplyOpts) error { ) // Build API client. - client := api.New(creds, + client := newApplyClient(creds, api.WithVerbose(opts.verbose), api.WithDebug(opts.debug), api.WithErrWriter(os.Stderr), @@ -106,18 +109,14 @@ func runApply(cmd *cobra.Command, jobID string, opts runApplyOpts) error { if err != nil { return fmt.Errorf("fetching job: %w", err) } - - // Print job header. - fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", detail.Title) - if detail.Company != "" || detail.Location != "" { - parts := []string{} - if detail.Company != "" { - parts = append(parts, detail.Company) - } - if detail.Location != "" { - parts = append(parts, detail.Location) - } - fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n", strings.Join(parts, " · ")) + if err := ensureApplicationCanProceed(detail); err != nil { + return err + } + if detail.Application.Status != types.ApplicationAccepting { + return writeNonEasyApplyResult(cmd.OutOrStdout(), w, detail, opts.json) + } + if !opts.json { + printApplyJobHeader(cmd.OutOrStdout(), detail) } // Step 2: Check Easy Apply availability. @@ -128,45 +127,21 @@ func runApply(cmd *cobra.Command, jobID string, opts runApplyOpts) error { } // Step 3: Not Easy Apply → external URL. - if !status.Available && !detail.EasyApply { - externalURL := api.ExternalApplyURL(jobID) - fmt.Fprintf(cmd.OutOrStdout(), "This job does not support Easy Apply.\n") - fmt.Fprintf(cmd.OutOrStdout(), "Apply externally at: %s\n", externalURL) - - if opts.json { - return w.JSON(map[string]interface{}{ - "easyApply": false, - "externalUrl": externalURL, - "jobId": detail.ID, - "title": detail.Title, - "company": detail.Company, - }) - } - return nil + if !status.Available { + detail.EasyApply = false + detail.Application.Status = types.ApplicationUnverified + detail.Application.Source = "linkedin-easy-apply-check" + detail.Application.Evidence = "LinkedIn did not return the current Easy Apply form control." + detail.Application.Reason = "Easy Apply availability could not be confirmed." + return writeNonEasyApplyResult(cmd.OutOrStdout(), w, detail, opts.json) } // Step 4: Show what will be submitted. - fmt.Fprintf(cmd.OutOrStdout(), "Easy Apply available ⚡\n\n") - fmt.Fprintf(cmd.OutOrStdout(), "Application summary:\n") - if status.Name != "" { - fmt.Fprintf(cmd.OutOrStdout(), " Name: %s\n", status.Name) + if !opts.json { + printEasyApplySummary(cmd.OutOrStdout(), status) } - if status.Email != "" { - fmt.Fprintf(cmd.OutOrStdout(), " Email: %s\n", status.Email) - } - if status.Phone != "" { - fmt.Fprintf(cmd.OutOrStdout(), " Phone: %s\n", status.Phone) - } - if status.Resume != "" { - fmt.Fprintf(cmd.OutOrStdout(), " Resume: %s\n", status.Resume) - } - if status.Name == "" && status.Email == "" { - fmt.Fprintf(cmd.OutOrStdout(), " (profile data will be submitted from your LinkedIn profile)\n") - } - fmt.Fprintln(cmd.OutOrStdout()) if opts.dryRun { - fmt.Fprintf(cmd.OutOrStdout(), "Dry run — not submitting.\n") if opts.json { return w.JSON(map[string]interface{}{ "dryRun": true, @@ -179,6 +154,7 @@ func runApply(cmd *cobra.Command, jobID string, opts runApplyOpts) error { "resume": status.Resume, }) } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Dry run — not submitting.") return nil } @@ -208,9 +184,6 @@ func runApply(cmd *cobra.Command, jobID string, opts runApplyOpts) error { } // Step 7: Success. - fmt.Fprintf(cmd.OutOrStdout(), "✓ Application submitted!\n") - fmt.Fprintf(cmd.OutOrStdout(), " %s at %s\n", detail.Title, detail.Company) - if opts.json { return w.JSON(map[string]interface{}{ "success": true, @@ -219,10 +192,85 @@ func runApply(cmd *cobra.Command, jobID string, opts runApplyOpts) error { "company": detail.Company, }) } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "✓ Application submitted!") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s at %s\n", detail.Title, detail.Company) + + return nil +} +func ensureApplicationCanProceed(detail *types.JobDetail) error { + if detail.Application.Status == types.ApplicationClosed { + return fmt.Errorf("job is closed; application will not be submitted") + } return nil } +func printApplyJobHeader(out io.Writer, detail *types.JobDetail) { + _, _ = fmt.Fprintf(out, "\n%s\n", detail.Title) + if detail.Company == "" && detail.Location == "" { + return + } + parts := make([]string, 0, 2) + if detail.Company != "" { + parts = append(parts, detail.Company) + } + if detail.Location != "" { + parts = append(parts, detail.Location) + } + _, _ = fmt.Fprintf(out, "%s\n\n", strings.Join(parts, " · ")) +} + +func printEasyApplySummary(out io.Writer, status *api.EasyApplyStatus) { + _, _ = fmt.Fprintln(out, "Easy Apply available ⚡") + _, _ = fmt.Fprintln(out, "\nApplication summary:") + if status.Name != "" { + _, _ = fmt.Fprintf(out, " Name: %s\n", status.Name) + } + if status.Email != "" { + _, _ = fmt.Fprintf(out, " Email: %s\n", status.Email) + } + if status.Phone != "" { + _, _ = fmt.Fprintf(out, " Phone: %s\n", status.Phone) + } + if status.Resume != "" { + _, _ = fmt.Fprintf(out, " Resume: %s\n", status.Resume) + } + if status.Name == "" && status.Email == "" { + _, _ = fmt.Fprintln(out, " (profile data will be submitted from your LinkedIn profile)") + } + _, _ = fmt.Fprintln(out) +} + +func writeNonEasyApplyResult(out io.Writer, writer *output.Writer, detail *types.JobDetail, jsonMode bool) error { + if jsonMode { + return writer.JSON(map[string]interface{}{ + "easyApply": false, + "externalUrl": detail.Application.ApplyURL, + "applyUrl": detail.Application.ApplyURL, + "listingUrl": detail.ListingURL, + "applicationStatus": detail.Application.Status, + "jobId": detail.ID, + "title": detail.Title, + "company": detail.Company, + }) + } + printApplyJobHeader(out, detail) + printNonEasyApply(out, detail) + return nil +} + +func printNonEasyApply(out io.Writer, detail *types.JobDetail) { + _, _ = fmt.Fprintln(out, "This job does not have a verified LinkedIn Easy Apply control.") + if detail.Application.ApplyURL != "" { + _, _ = fmt.Fprintf(out, "Observed employer application URL (unverified): %s\n", detail.Application.ApplyURL) + } else { + _, _ = fmt.Fprintln(out, "No employer application URL was observed.") + } + if detail.ListingURL != "" { + _, _ = fmt.Fprintf(out, "LinkedIn listing: %s\n", detail.ListingURL) + } +} + // isTerminalStdin reports whether stdin is a real terminal. func isTerminalStdin() bool { fi, err := os.Stdin.Stat() diff --git a/internal/cmd/apply_test.go b/internal/cmd/apply_test.go new file mode 100644 index 0000000..75423e4 --- /dev/null +++ b/internal/cmd/apply_test.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "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 applyRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn applyRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestEnsureApplicationCanProceedRejectsKnownClosed(t *testing.T) { + detail := &types.JobDetail{ + Application: types.ApplicationAvailability{Status: types.ApplicationClosed}, + } + if err := ensureApplicationCanProceed(detail); err == nil { + t.Fatal("known closed job was allowed to proceed") + } +} + +func TestApplyExternalJSONIsCleanAndBackwardCompatible(t *testing.T) { + applyURL := "https://jobs.lever.co/example/current/apply" + detailBody := `{"data":{"jobsDashJobPostingDetailSectionsByCardSectionTypes":{"elements":[{"jobPostingDetailSection":[{"topCardV2":{"jobPostingCard":{"jobPostingTitle":"Example role","jobPosting":{"jobState":"LISTED"},"primaryActionV2":{"applyJobAction":{"applyJobActionResolutionResult":{"applicantTrackingSystemName":"Lever","onsiteApply":false,"applyCtaText":{"text":"Apply"},"companyApplyUrl":"` + applyURL + `"}}}}}}]}]}}}` + value := runApplyJSONCommand(t, detailBody, `{"data":{"jobsDashOnsiteApplyApplicationByJobPosting":{"elements":[]}}}`) + if value["externalUrl"] != applyURL || value["applyUrl"] != applyURL { + t.Fatalf("URLs = %#v", value) + } + if value["listingUrl"] == applyURL { + t.Fatal("listing URL was conflated with employer URL") + } +} + +func TestApplyDryRunJSONIsClean(t *testing.T) { + detailBody := `{"data":{"jobsDashJobPostingDetailSectionsByCardSectionTypes":{"elements":[{"jobPostingDetailSection":[{"topCardV2":{"jobPostingCard":{"jobPostingTitle":"Example role","jobPosting":{"jobState":"LISTED"},"primaryActionV2":{"applyJobAction":{"applyJobActionResolutionResult":{"onsiteApply":true,"applyCtaText":{"text":"Easy Apply"}}}}}}}]}]}}}` + applyBody := `{"data":{"jobsDashOnsiteApplyApplicationByJobPosting":{"elements":[{"jobSeekerApplicationDetail":{"onsiteApply":true,"applyCtaText":{"text":"Easy Apply"},"emailAddress":"candidate@example.com"}}]}}}` + value := runApplyJSONCommand(t, detailBody, applyBody, "--dry-run") + if value["dryRun"] != true || value["easyApply"] != true { + t.Fatalf("result = %#v", value) + } +} + +func TestApplyUnavailableCheckJSONIsUnverified(t *testing.T) { + detailBody := `{"data":{"jobsDashJobPostingDetailSectionsByCardSectionTypes":{"elements":[{"jobPostingDetailSection":[{"topCardV2":{"jobPostingCard":{"jobPostingTitle":"Example role","jobPosting":{"jobState":"LISTED"},"primaryActionV2":{"applyJobAction":{"applyJobActionResolutionResult":{"onsiteApply":true,"applyCtaText":{"text":"Easy Apply"}}}}}}}]}]}}}` + value := runApplyJSONCommand(t, detailBody, `{"data":{"jobsDashOnsiteApplyApplicationByJobPosting":{"elements":[]}}}`) + if value["applicationStatus"] != "unverified" || value["externalUrl"] != "" { + t.Fatalf("result = %#v", value) + } +} + +func runApplyJSONCommand(t *testing.T, detailBody, applyBody string, flags ...string) map[string]interface{} { + t.Helper() + t.Setenv("LNK_LI_AT", "test-session") + t.Setenv("LNK_CSRF_TOKEN", "test-csrf") + originalFactory := newApplyClient + newApplyClient = func(creds auth.Credentials, options ...api.Option) *api.Client { + client := &http.Client{Transport: applyRoundTripFunc(func(request *http.Request) (*http.Response, error) { + body := applyBody + if request.URL.Query().Get("queryName") == "JobPostingDetailSectionsByCardSectionTypesV2" { + body = detailBody + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + })} + return api.New(creds, append(options, api.WithHTTPClient(client))...) + } + t.Cleanup(func() { newApplyClient = originalFactory }) + + jsonMode := true + otherFlag := false + command := NewApplyCmd(&otherFlag, &jsonMode, &otherFlag, &otherFlag, &otherFlag, &otherFlag, &otherFlag) + var output bytes.Buffer + command.SetOut(&output) + command.SetArgs(append([]string{"1234567890"}, flags...)) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + var value map[string]interface{} + if err := json.Unmarshal(output.Bytes(), &value); err != nil { + t.Fatalf("stdout is not clean JSON: %v; output=%q", err, output.String()) + } + return value +} + +func TestPrintNonEasyApplyDoesNotInventEmployerURL(t *testing.T) { + tests := []struct { + name string + applyURL string + want string + notWanted string + }{ + { + name: "observed employer URL", + applyURL: "https://jobs.lever.co/example/id/apply", + want: "Observed employer application URL (unverified): https://jobs.lever.co/example/id/apply", + notWanted: "Apply externally at", + }, + { + name: "listing only", + want: "No employer application URL was observed.", + notWanted: "Apply externally at", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + detail := &types.JobDetail{ + JobCard: types.JobCard{ListingURL: "https://www.linkedin.com/jobs/view/1234567890"}, + Application: types.ApplicationAvailability{ApplyURL: test.applyURL}, + } + var output bytes.Buffer + printNonEasyApply(&output, detail) + if !strings.Contains(output.String(), test.want) { + t.Fatalf("output %q does not contain %q", output.String(), test.want) + } + if strings.Contains(output.String(), test.notWanted) { + t.Fatalf("output %q contains misleading wording %q", output.String(), test.notWanted) + } + }) + } +} diff --git a/internal/cmd/job.go b/internal/cmd/job.go index c8cdda6..8d4b506 100644 --- a/internal/cmd/job.go +++ b/internal/cmd/job.go @@ -18,7 +18,7 @@ import ( // NewJobCmd returns the "lnk job" command. func NewJobCmd(noInput, flagJSON, flagPlain, flagQuiet, flagVerbose, flagDebug, flagNoColor *bool) *cobra.Command { - var flagOpen bool + var flagOpen, flagCheckApply bool cmd := &cobra.Command{ Use: "job ", @@ -30,24 +30,26 @@ The job-id can be a bare numeric ID (e.g. 4418763611) or a full URN Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runJobDetail(cmd, args[0], runJobDetailOpts{ - json: *flagJSON, - plain: *flagPlain, - quiet: *flagQuiet, - verbose: *flagVerbose, - debug: *flagDebug, - noColor: *flagNoColor, - open: flagOpen, + json: *flagJSON, + plain: *flagPlain, + quiet: *flagQuiet, + verbose: *flagVerbose, + debug: *flagDebug, + noColor: *flagNoColor, + open: flagOpen, + checkApply: flagCheckApply, }) }, } cmd.Flags().BoolVar(&flagOpen, "open", false, "Open the job URL in the default browser") + cmd.Flags().BoolVar(&flagCheckApply, "check-apply", false, "Verify whether the application route is currently accepting") return cmd } type runJobDetailOpts struct { - json, plain, quiet, verbose, debug, noColor, open bool + json, plain, quiet, verbose, debug, noColor, open, checkApply bool } func runJobDetail(cmd *cobra.Command, jobID string, opts runJobDetailOpts) error { @@ -93,6 +95,9 @@ func runJobDetail(cmd *cobra.Command, jobID string, opts runJobDetailOpts) error if err != nil { return fmt.Errorf("fetching job: %w", err) } + if opts.checkApply { + detail.Application = client.CheckApplication(cmd.Context(), detail) + } // Open in browser if requested. if opts.open { @@ -169,6 +174,20 @@ func printJobDetailHuman(out io.Writer, d *types.JobDetail) { fmt.Fprintf(out, "\n⚠ This job posting has expired.\n") } + _, _ = fmt.Fprintf(out, "\nApplication: %s\n", d.Application.Status) + if d.Application.Evidence != "" { + _, _ = fmt.Fprintf(out, "Evidence: %s\n", d.Application.Evidence) + } + if d.Application.Reason != "" { + _, _ = fmt.Fprintf(out, "Reason: %s\n", d.Application.Reason) + } + if d.Application.CheckedAt != "" { + _, _ = fmt.Fprintf(out, "Checked: %s\n", d.Application.CheckedAt) + } + if d.Application.ApplyURL != "" { + _, _ = fmt.Fprintf(out, "Apply URL: %s\n", d.Application.ApplyURL) + } + // Description section. if d.Description != "" { fmt.Fprintf(out, "\nDescription:\n") @@ -199,9 +218,8 @@ func printJobDetailHuman(out io.Writer, d *types.JobDetail) { ) } - // Listing URL. if d.ListingURL != "" { - fmt.Fprintf(out, "\nURL: %s\n", d.ListingURL) + _, _ = fmt.Fprintf(out, "\nListing URL: %s\n", d.ListingURL) } } @@ -215,7 +233,7 @@ func printJobDetailPlain(w *output.Writer, d *types.JobDetail) { // Collapse newlines for plain output. desc = strings.ReplaceAll(desc, "\n", " ") - w.Plain(d.ID, d.Title, d.Company, d.Location, d.PostedAt, easyApply, d.Salary, desc) + w.Plain(d.ID, d.Title, d.Company, d.Location, d.PostedAt, easyApply, d.Salary, desc, string(d.Application.Status), d.Application.ApplyURL) } // formatSalary formats a salary integer with thousands separators. diff --git a/internal/cmd/job_test.go b/internal/cmd/job_test.go new file mode 100644 index 0000000..5c19619 --- /dev/null +++ b/internal/cmd/job_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/yashiels/linkedin-cli/internal/types" +) + +func TestJobCommandHasCheckApplyFlag(t *testing.T) { + value := false + command := NewJobCmd(&value, &value, &value, &value, &value, &value, &value) + flag := command.Flags().Lookup("check-apply") + if flag == nil { + t.Fatal("missing --check-apply flag") + } +} + +func TestJobDetailJSONPreservesFieldsAndIncludesUnverifiedApplication(t *testing.T) { + detail := types.JobDetail{ + JobCard: types.JobCard{ + ID: "1234567890", + Title: "Example role", + ListingURL: "https://www.linkedin.com/jobs/view/1234567890", + }, + Expired: false, + Application: types.ApplicationAvailability{ + Status: types.ApplicationUnverified, + Source: "linkedin-control", + Reason: "LinkedIn returned no sufficient application-control evidence.", + }, + } + encoded, err := json.Marshal(detail) + if err != nil { + t.Fatal(err) + } + var value map[string]interface{} + if err := json.Unmarshal(encoded, &value); err != nil { + t.Fatal(err) + } + for _, field := range []string{"id", "title", "listingUrl", "expired", "application"} { + if _, ok := value[field]; !ok { + t.Fatalf("missing JSON field %q", field) + } + } + application := value["application"].(map[string]interface{}) + if application["status"] != "unverified" { + t.Fatalf("application status = %#v", application["status"]) + } +} + +func TestPrintJobDetailHumanSeparatesListingAndApplyURLs(t *testing.T) { + detail := &types.JobDetail{ + JobCard: types.JobCard{ + Title: "Example role", + ListingURL: "https://www.linkedin.com/jobs/view/1234567890", + }, + Application: types.ApplicationAvailability{ + Status: types.ApplicationUnverified, + Reason: "Employer form has not been verified.", + ApplyURL: "https://jobs.lever.co/example/id/apply", + }, + } + var output bytes.Buffer + printJobDetailHuman(&output, detail) + text := output.String() + for _, want := range []string{ + "Application: unverified", + "Apply URL: https://jobs.lever.co/example/id/apply", + "Listing URL: https://www.linkedin.com/jobs/view/1234567890", + } { + if !strings.Contains(text, want) { + t.Fatalf("output %q does not contain %q", text, want) + } + } +} diff --git a/internal/types/jobs.go b/internal/types/jobs.go index 97f5f6e..b62de72 100644 --- a/internal/types/jobs.go +++ b/internal/types/jobs.go @@ -16,20 +16,39 @@ type JobCard struct { ListingURL string `json:"listingUrl,omitempty"` } +type ApplicationStatus string + +const ( + ApplicationAccepting ApplicationStatus = "accepting" + ApplicationClosed ApplicationStatus = "closed" + ApplicationUnverified ApplicationStatus = "unverified" +) + +type ApplicationAvailability struct { + Status ApplicationStatus `json:"status"` + Source string `json:"source"` + Evidence string `json:"evidence,omitempty"` + Reason string `json:"reason"` + CheckedAt string `json:"checkedAt,omitempty"` + ApplyURL string `json:"applyUrl,omitempty"` + ApplicantTrackingSystem string `json:"applicantTrackingSystem,omitempty"` +} + // JobDetail is the full representation of a job posting. type JobDetail struct { JobCard - Description string `json:"description"` - Salary string `json:"salary,omitempty"` - SalaryMin int64 `json:"salaryMin,omitempty"` - SalaryMax int64 `json:"salaryMax,omitempty"` - SalaryCurr string `json:"salaryCurrency,omitempty"` - SeniorityLevel string `json:"seniorityLevel,omitempty"` - EmploymentType string `json:"employmentType,omitempty"` - Industries []string `json:"industries,omitempty"` - Skills []string `json:"skills,omitempty"` - HiringManager string `json:"hiringManager,omitempty"` - ClosedAt string `json:"closedAt,omitempty"` - Expired bool `json:"expired"` + Description string `json:"description"` + Salary string `json:"salary,omitempty"` + SalaryMin int64 `json:"salaryMin,omitempty"` + SalaryMax int64 `json:"salaryMax,omitempty"` + SalaryCurr string `json:"salaryCurrency,omitempty"` + SeniorityLevel string `json:"seniorityLevel,omitempty"` + EmploymentType string `json:"employmentType,omitempty"` + Industries []string `json:"industries,omitempty"` + Skills []string `json:"skills,omitempty"` + HiringManager string `json:"hiringManager,omitempty"` + ClosedAt string `json:"closedAt,omitempty"` + Expired bool `json:"expired"` + Application ApplicationAvailability `json:"application"` } diff --git a/skill/SKILL.md b/skill/SKILL.md index b7a88ee..52dde20 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -68,7 +68,7 @@ CI or headless use without writing cookies to disk): | Command | Description | |---|---| | `lnk search [flags]` | Search job listings | -| `lnk job [--open]` | View full details of a posting (`job-id` bare numeric or `urn:li:fsd_jobPosting:`); `--open` opens in browser | +| `lnk job [--open] [--check-apply]` | View full details; `--check-apply` verifies the current application route and may anonymously fetch a supported employer ATS URL | | `lnk feed [-n/--limit N]` | Recommended job feed (Jobs You May Be Interested In) | `search` flags: @@ -86,14 +86,47 @@ CI or headless use without writing cookies to disk): ## Apply +Job JSON preserves all existing fields and adds `application`: + +- `status`: `accepting`, `closed`, or `unverified`. +- `source`, `evidence`, and `reason`: what supports the status and why. +- `checkedAt`: when the current LinkedIn or employer check ran. +- `applyUrl`: a real employer URL observed in LinkedIn data, when present. This + is distinct from `listingUrl`. +- `applicantTrackingSystem`: the ATS name observed in LinkedIn data, when present. + +Use `lnk job --check-apply --json` before treating a discovery result as +apply-now. `accepting` requires LinkedIn `LISTED` plus `onsiteApply: true` and the +exact Easy Apply CTA, an identified Lever POST form with an active submission +control inside it, or Workday JSON with boolean `jobPostingInfo.canApply: true`. +`closed` requires LinkedIn `CLOSED`/`SUSPENDED` or Workday JSON +`jobPostingInfo.canApply: false`. Everything else is `unverified`: missing or disabled controls, +unknown job state, unsupported ATS sites, login/challenge walls, failed requests, +and pages that merely return HTTP 200. `expired: false`, `jobState: LISTED`, a +generated listing URL, a form of an unknown kind, or “Apply” text in a description +are not proof. + +External verification is intentionally narrow. Lever and Workday are the only +supported employer checks. Workday landing HTML is not JSON proof and remains +`unverified`. The checker uses HTTPS only, permits at most three same-posting safe +redirects, has an eight-second timeout and 1 MiB body limit, rejects non-public +destinations, closes idle connections after each verification, and never sends +LinkedIn cookies, Authorization, or CSRF headers to employers. + +Lever free text is never treated as closure proof. If its parsed application form +and control checks fail, the result is `unverified`; dated deadlines remain a +separate manual check against the current job description. + | Command | Description | |---|---| | `lnk apply [--dry-run] [--confirm]` | Apply via Easy Apply | `apply` fetches the job, checks Easy Apply availability, shows what will be -submitted (your profile data), asks for confirmation, then submits. If Easy -Apply is not available it prints the external application URL and submits -nothing. `--dry-run` previews without submitting; `--confirm` skips the prompt. +submitted (your profile data), asks for confirmation, then submits. A known-closed +job is rejected. If Easy Apply is unavailable, an observed employer URL is labeled +unverified, or the command states that no employer URL was observed. It never +labels the generated LinkedIn listing URL as an employer URL. `--dry-run` previews +without submitting; `--confirm` skips the prompt. ## Saved jobs @@ -128,7 +161,7 @@ prompting), `--config `. ## Headless / agent usage **Read commands are safe to run unattended once credentials exist:** `search`, -`job` (without `--open`), `feed`, `profile`, `saved list`, `alerts list`, +`job` (without `--open`, including `--check-apply`), `feed`, `profile`, `saved list`, `alerts list`, `status`, `auth status`. Add `--json` (or `--plain`) to parse output. Avoid `job --open`, which launches a browser. @@ -159,6 +192,20 @@ explicitly asks:** - `lnk alerts create` / `lnk alerts delete` — modify job-alert subscriptions. - `lnk auth logout` — deletes stored credentials. +Never run an application, saved-job, or alert write without explicit consent for +that exact action. Discovery is not consent. + +## Agent job-verification workflow + +1. Deduplicate daily search/feed results by job `id` before fetching details. +2. Run `lnk job --check-apply --json` for each candidate under review. +3. Check the current description for a closing date. A passed or ambiguous date + excludes the job from apply-now even if the route reports `accepting`. +4. Include only `application.status == "accepting"` in an apply-now shortlist. + Put `unverified` entries in a clearly separate manual-review queue. +5. Evaluate suitability separately. State missing qualifications or experience + explicitly; CLI application proof does not establish candidate fit. + ## Typical flow ```sh @@ -168,7 +215,7 @@ lnk status # confirm logged in + API connected # 2. Search and inspect jobs lnk search "backend engineer" --location "Cape Town" --easy-apply --json -lnk job 4414623196 # full detail for one posting +lnk job 4414623196 --check-apply --json lnk feed --limit 10 # personalised recommendations # 3. Preview then apply (Easy Apply) From af4db70a9fb00dda6481f12b6dc17a81f49124e7 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Fri, 2 Oct 2026 14:38:45 +0200 Subject: [PATCH 2/2] fix(jobs): address application verifier review findings #11 --- README.md | 8 ++- internal/api/application.go | 1 + internal/api/application_html.go | 31 ++++++-- internal/api/application_test.go | 98 +++++++++++++++++++++++++ internal/api/jobdetail.go | 83 +++++++++++++++++++--- internal/api/jobdetail_test.go | 118 +++++++++++++++++++++++++++++++ skill/SKILL.md | 11 +++ 7 files changed, 334 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9ff8b6c..3d6f650 100644 --- a/README.md +++ b/README.md @@ -116,10 +116,16 @@ fields. Its status is deliberately conservative: says “Apply,” or the presence of an employer URL does not mean a job is accepting applications. `listingUrl` is the LinkedIn listing; `application.applyUrl` is an employer application URL observed in LinkedIn data. They are never interchangeable. +For the same URL, blank ATS metadata does not replace a real provider. Conflicting +non-empty ATS names remain `unverified`. When LinkedIn returns multiple employer +URLs, one deterministic observed URL is retained without choosing a provider. `--check-apply` supports narrow proof checks for Lever and Workday. Lever must return the identified posting's POST application form with an active submission -control inside that form. Workday must return JSON containing a boolean +control inside that form. The control and its ancestors must also be visible by +the supported HTML signals: `hidden`, `aria-hidden=true`, a `hidden` class, or +inline `display:none`/`visibility:hidden`. This is not a complete CSS layout +engine. Workday must return JSON containing a boolean `jobPostingInfo.canApply`; a normal Workday HTML landing page alone remains `unverified`. Other ATS destinations remain `unverified` and retain their observed URL for manual review. Employer checks require HTTPS, use an anonymous client that diff --git a/internal/api/application.go b/internal/api/application.go index a98b726..b9ff7db 100644 --- a/internal/api/application.go +++ b/internal/api/application.go @@ -40,6 +40,7 @@ var nonPublicApplicationNetworks = []netip.Prefix{ netip.MustParsePrefix("2001:20::/28"), netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("fec0::/10"), } type employerApplicationChecker struct { diff --git a/internal/api/application_html.go b/internal/api/application_html.go index 890dd9a..515c5a7 100644 --- a/internal/api/application_html.go +++ b/internal/api/application_html.go @@ -4,6 +4,7 @@ import ( "bytes" "net/url" "strings" + "unicode" "golang.org/x/net/html" ) @@ -143,21 +144,41 @@ func leverControlDisabled(control, form *html.Node) bool { } func leverControlHidden(control *html.Node, controlType string) bool { - if htmlBooleanAttribute(control, "hidden") || controlType == "hidden" { + if controlType == "hidden" { return true } - ariaHidden, _ := htmlAttribute(control, "aria-hidden") + for node := control; node != nil; node = node.Parent { + if htmlNodeHidden(node) { + return true + } + } + return false +} + +func htmlNodeHidden(node *html.Node) bool { + if node.Type != html.ElementNode { + return false + } + if htmlBooleanAttribute(node, "hidden") { + return true + } + ariaHidden, _ := htmlAttribute(node, "aria-hidden") if strings.EqualFold(strings.TrimSpace(ariaHidden), "true") { return true } - className, _ := htmlAttribute(control, "class") + className, _ := htmlAttribute(node, "class") for _, value := range strings.Fields(strings.ToLower(className)) { if value == "hidden" { return true } } - style, _ := htmlAttribute(control, "style") - style = strings.ReplaceAll(strings.ToLower(style), " ", "") + style, _ := htmlAttribute(node, "style") + style = strings.Map(func(character rune) rune { + if unicode.IsSpace(character) { + return -1 + } + return character + }, strings.ToLower(style)) return strings.Contains(style, "display:none") || strings.Contains(style, "visibility:hidden") } diff --git a/internal/api/application_test.go b/internal/api/application_test.go index 8450ed4..4b1a26a 100644 --- a/internal/api/application_test.go +++ b/internal/api/application_test.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/http/httptest" + "net/netip" "strings" "sync" "testing" @@ -299,6 +300,103 @@ func TestEmployerApplicationVerificationRejectsUnsafeDestinations(t *testing.T) } } +func TestIPv6SiteLocalBoundary(t *testing.T) { + siteLocal := netip.MustParsePrefix("fec0::/10") + membership := []struct { + address string + inside bool + }{ + {address: "febf::", inside: false}, + {address: "fec0::", inside: true}, + {address: "feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", inside: true}, + } + for _, test := range membership { + address := netip.MustParseAddr(test.address) + if siteLocal.Contains(address) != test.inside { + t.Fatalf("fec0::/10 membership for %s = %t, want %t", address, siteLocal.Contains(address), test.inside) + } + } + + publicity := []struct { + address string + public bool + }{ + {address: "febf::", public: false}, + {address: "fec0::", public: false}, + {address: "feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", public: false}, + {address: "2001:4860:4860::8888", public: true}, + } + for _, test := range publicity { + got := isPublicApplicationIP(net.ParseIP(test.address)) + if got != test.public { + t.Fatalf("public status for %s = %t, want %t", test.address, got, test.public) + } + } +} + +func TestLeverApplicationVerificationRespectsAncestorVisibility(t *testing.T) { + tests := []struct { + name string + body string + want types.ApplicationStatus + }{ + { + name: "hidden form", + body: ``, + want: types.ApplicationUnverified, + }, + { + name: "aria hidden form", + body: ``, + want: types.ApplicationUnverified, + }, + { + name: "display none form", + body: ``, + want: types.ApplicationUnverified, + }, + { + name: "hidden wrapper", + body: `
`, + want: types.ApplicationUnverified, + }, + { + name: "hidden wrapper outside form", + body: ``, + want: types.ApplicationUnverified, + }, + { + name: "aria hidden wrapper", + body: `
`, + want: types.ApplicationUnverified, + }, + { + name: "visibility hidden wrapper", + body: "
", + want: types.ApplicationUnverified, + }, + { + name: "hidden class wrapper", + body: `
`, + want: types.ApplicationUnverified, + }, + { + name: "visible form and wrapper", + body: `
`, + want: types.ApplicationAccepting, + }, + } + base := types.ApplicationAvailability{Status: types.ApplicationUnverified, ApplyURL: "https://jobs.lever.co/example/current/apply"} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := evaluateEmployerResponse(base, "lever", http.StatusOK, []byte(test.body)) + if result.Status != test.want { + t.Fatalf("status = %q, want %q", result.Status, test.want) + } + }) + } +} + func TestEmployerApplicationVerificationRejectsDifferentPostingRedirect(t *testing.T) { checker := testEmployerChecker(t, func(writer http.ResponseWriter, request *http.Request) { if request.URL.Path == "/example/current/apply" { diff --git a/internal/api/jobdetail.go b/internal/api/jobdetail.go index 495b236..54cd317 100644 --- a/internal/api/jobdetail.go +++ b/internal/api/jobdetail.go @@ -123,7 +123,7 @@ func parseJobDetail(raw json.RawMessage, originalID string) (*types.JobDetail, e Reason: "LinkedIn returned no sufficient application-control evidence.", }, } - observations := applicationObservations{} + observations := applicationObservations{routes: map[string]*applicationRouteObservation{}} // Determine the bare numeric ID for URLs etc. bareID := originalID @@ -187,8 +187,11 @@ type applicationObservations struct { contradictory bool listed bool closedState string - applyURL string - ats string + routes map[string]*applicationRouteObservation +} + +type applicationRouteObservation struct { + ats map[string]string } // parseTopCard extracts header information from a topCardV2 section object. @@ -271,10 +274,7 @@ func observeApplicationAvailability(observations *applicationObservations, jpc i } ats := strPath(resolution, "applicantTrackingSystemName") applyURL := strPath(resolution, "companyApplyUrl") - if applyURL != "" && (observations.applyURL == "" || applyURL < observations.applyURL) { - observations.applyURL = applyURL - observations.ats = ats - } + observeApplicationRoute(observations, applyURL, ats) cta := strings.Join(strings.Fields(strings.ToLower(strPath(resolution, "applyCtaText", "text"))), " ") onsiteApply, onsitePresent := nav(resolution, "onsiteApply").(bool) positive := jobState == "LISTED" && onsitePresent && onsiteApply && cta == "easy apply" @@ -287,9 +287,62 @@ func observeApplicationAvailability(observations *applicationObservations, jpc i } } +func observeApplicationRoute(observations *applicationObservations, applyURL, ats string) { + if applyURL == "" { + return + } + route := observations.routes[applyURL] + if route == nil { + route = &applicationRouteObservation{ats: map[string]string{}} + observations.routes[applyURL] = route + } + canonical := canonicalApplicantTrackingSystem(ats) + if canonical == "" { + return + } + key := strings.ToLower(canonical) + if existing := route.ats[key]; existing == "" || canonical < existing { + route.ats[key] = canonical + } +} + +func canonicalApplicantTrackingSystem(ats string) string { + trimmed := strings.TrimSpace(ats) + switch strings.ToLower(trimmed) { + case "lever": + return "Lever" + case "workday": + return "Workday" + default: + return trimmed + } +} + +func selectApplicationRoute(observations applicationObservations) (string, string, bool, bool) { + applyURL := "" + for observedURL := range observations.routes { + if applyURL == "" || observedURL < applyURL { + applyURL = observedURL + } + } + if applyURL == "" { + return "", "", false, false + } + route := observations.routes[applyURL] + multipleURLs := len(observations.routes) > 1 + if multipleURLs || len(route.ats) > 1 { + return applyURL, "", len(route.ats) > 1, multipleURLs + } + for _, ats := range route.ats { + return applyURL, ats, false, false + } + return applyURL, "", false, false +} + func applyApplicationObservations(d *types.JobDetail, observations applicationObservations) { - d.Application.ApplyURL = observations.applyURL - d.Application.ApplicantTrackingSystem = observations.ats + applyURL, ats, conflictingATS, multipleURLs := selectApplicationRoute(observations) + d.Application.ApplyURL = applyURL + d.Application.ApplicantTrackingSystem = ats d.Application.Source = "linkedin-control" if observations.closedState != "" { d.Expired = true @@ -309,12 +362,22 @@ func applyApplicationObservations(d *types.JobDetail, observations applicationOb } d.EasyApply = false d.Application.Status = types.ApplicationUnverified + if conflictingATS { + d.Application.Evidence = "LinkedIn returned conflicting applicant tracking system metadata for the same employer application URL." + d.Application.Reason = "The employer application provider could not be selected safely." + return + } + if multipleURLs { + d.Application.Evidence = "LinkedIn returned multiple employer application URLs." + d.Application.Reason = "A deterministic observed URL was retained without selecting an application provider." + return + } if observations.positive && observations.contradictory { d.Application.Evidence = "LinkedIn returned contradictory application-control metadata." d.Application.Reason = "Application availability could not be verified consistently." return } - if observations.applyURL != "" { + if applyURL != "" { d.Application.Evidence = "LinkedIn returned an employer application URL." d.Application.Reason = "The employer application form has not been verified." return diff --git a/internal/api/jobdetail_test.go b/internal/api/jobdetail_test.go index 6570381..165e571 100644 --- a/internal/api/jobdetail_test.go +++ b/internal/api/jobdetail_test.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "strings" "testing" "github.com/yashiels/linkedin-cli/internal/types" @@ -280,6 +281,123 @@ func TestParseJobDetailMissingOptionalControlDoesNotContradictPositiveEvidence(t } } +func TestParseJobDetailMergesSameURLATSDeterministically(t *testing.T) { + leverURL := "https://jobs.lever.co/example/current/apply" + workdayURL := "https://example.wd3.myworkdayjobs.com/en-US/example/job/current" + tests := []struct { + name string + url string + first string + second string + wantATS string + }{ + {name: "blank then Lever", url: leverURL, second: "Lever", wantATS: "Lever"}, + {name: "Lever then blank", url: leverURL, first: "Lever", wantATS: "Lever"}, + {name: "blank then Workday", url: workdayURL, second: "Workday", wantATS: "Workday"}, + {name: "Workday then blank", url: workdayURL, first: "Workday", wantATS: "Workday"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + detail := parseExternalApplicationObservations(t, []externalApplicationObservation{ + {url: test.url, ats: test.first}, + {url: test.url, ats: test.second}, + }) + if detail.Application.ApplyURL != test.url || detail.Application.ApplicantTrackingSystem != test.wantATS { + t.Fatalf("application = %#v", detail.Application) + } + }) + } +} + +func TestParseJobDetailConflictingSameURLATSIsUnverified(t *testing.T) { + applyURL := "https://jobs.lever.co/example/current/apply" + tests := []struct { + name string + first string + second string + }{ + {name: "stale then Lever", first: "LegacyATS", second: "Lever"}, + {name: "Lever then stale", first: "Lever", second: "LegacyATS"}, + {name: "Lever then Workday", first: "Lever", second: "Workday"}, + {name: "Workday then Lever", first: "Workday", second: "Lever"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + detail := parseExternalApplicationObservations(t, []externalApplicationObservation{ + {url: applyURL, ats: test.first}, + {url: applyURL, ats: test.second}, + }) + if detail.Application.Status != types.ApplicationUnverified || detail.Application.ApplicantTrackingSystem != "" { + t.Fatalf("application = %#v", detail.Application) + } + if !strings.Contains(strings.ToLower(detail.Application.Evidence), "conflicting") { + t.Fatalf("evidence = %q", detail.Application.Evidence) + } + }) + } +} + +func TestParseJobDetailMultipleExternalURLsRemainDeterministic(t *testing.T) { + firstURL := "https://careers.example.com/jobs/z" + secondURL := "https://careers.example.com/jobs/a" + for _, observations := range [][]externalApplicationObservation{ + {{url: firstURL, ats: "FirstATS"}, {url: secondURL, ats: "SecondATS"}}, + {{url: secondURL, ats: "SecondATS"}, {url: firstURL, ats: "FirstATS"}}, + } { + detail := parseExternalApplicationObservations(t, observations) + if detail.Application.Status != types.ApplicationUnverified || detail.Application.ApplyURL != secondURL || detail.Application.ApplicantTrackingSystem != "" { + t.Fatalf("application = %#v", detail.Application) + } + if !strings.Contains(strings.ToLower(detail.Application.Evidence), "multiple") { + t.Fatalf("evidence = %q", detail.Application.Evidence) + } + } +} + +type externalApplicationObservation struct { + url string + ats string +} + +func parseExternalApplicationObservations(t *testing.T, observations []externalApplicationObservation) *types.JobDetail { + t.Helper() + sections := make([]interface{}, 0, len(observations)) + for _, observation := range observations { + sections = append(sections, map[string]interface{}{ + "topCardV2": map[string]interface{}{ + "jobPostingCard": map[string]interface{}{ + "jobPosting": map[string]interface{}{"jobState": "LISTED"}, + "primaryActionV2": map[string]interface{}{ + "applyJobAction": map[string]interface{}{ + "applyJobActionResolutionResult": map[string]interface{}{ + "onsiteApply": false, + "applyCtaText": map[string]interface{}{"text": "Apply"}, + "companyApplyUrl": observation.url, + "applicantTrackingSystemName": observation.ats, + }, + }, + }, + }, + }, + }) + } + raw, err := json.Marshal(map[string]interface{}{ + "data": map[string]interface{}{ + "jobsDashJobPostingDetailSectionsByCardSectionTypes": map[string]interface{}{ + "elements": []interface{}{map[string]interface{}{"jobPostingDetailSection": sections}}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + detail, err := parseJobDetail(raw, "1234567890") + if err != nil { + t.Fatal(err) + } + return detail +} + func TestParseEasyApplyCheckRequiresEnabledOnsiteControl(t *testing.T) { tests := []struct { name string diff --git a/skill/SKILL.md b/skill/SKILL.md index 52dde20..130b4ab 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -95,6 +95,11 @@ Job JSON preserves all existing fields and adds `application`: is distinct from `listingUrl`. - `applicantTrackingSystem`: the ATS name observed in LinkedIn data, when present. +For one observed URL, empty ATS metadata is ignored when a real provider name is +also present. Conflicting non-empty names clear the provider and remain +`unverified`. Multiple observed URLs retain one deterministic URL without choosing +an ATS, so no arbitrary destination is externally verified. + Use `lnk job --check-apply --json` before treating a discovery result as apply-now. `accepting` requires LinkedIn `LISTED` plus `onsiteApply: true` and the exact Easy Apply CTA, an identified Lever POST form with an active submission @@ -113,6 +118,12 @@ redirects, has an eight-second timeout and 1 MiB body limit, rejects non-public destinations, closes idle connections after each verification, and never sends LinkedIn cookies, Authorization, or CSRF headers to employers. +Lever uses the official Go HTML5 parser. A submission control must belong to the +validated form, preserve POST and the posting target, and have no supported hidden +signal on itself or an ancestor: `hidden`, `aria-hidden=true`, a `hidden` class, +or inline `display:none`/`visibility:hidden`. Full stylesheet layout is outside +the verifier's scope. + Lever free text is never treated as closure proof. If its parsed application form and control checks fail, the result is `unverified`; dated deadlines remain a separate manual check against the current job description.