Files
scarf/scarf/scarfTests/NousAuthFlowTests.swift
T
Alan Wizemann 257772e2d1 feat(nous): in-app sign-in + credential pools auth-type gating
The Tool Gateway feature shipped the Nous Portal provider in Scarf's
picker, a subscription-state detector, and a per-task aux toggle — but
there was no way to actually sign in. `hermes auth` in a terminal took
six steps, and Credential Pools' "Start OAuth" button silently stalled
for `nous` because it tried to run the PKCE flow against a device-code
provider.

Changes:

- NousAuthFlow: new @Observable MainActor service that spawns
  `hermes auth add nous --no-browser`, parses the device-code block
  (verification_uri_complete + user_code) with two line-anchored
  regexes, opens the verification URL via NSWorkspace.shared.open,
  and confirms success by re-reading auth.json via
  NousSubscriptionService. Detects the `subscription_required`
  failure and extracts the billing URL so the UI can offer a
  Subscribe link.
- NousSignInSheet: four-state sheet (starting / waitingForApproval /
  success / failure). Shows the user code in a large monospaced
  badge with Copy + re-open-browser affordances, auto-dismisses
  1.2s after success, Subscribe + Try again + Copy error buttons
  on failure.
- Wired three entry points (per user-approved plan):
    1. ModelPickerSheet's Nous Portal subscription summary — replaces
       the stale "Run hermes auth" caption with a primary
       "Sign in to Nous Portal" button.
    2. AuxiliaryTab's per-task Nous toggle — inline "Sign in first"
       button when not subscribed, instead of a dead-end caption.
    3. Credential Pools "Add Credential" sheet — when provider is
       `nous`, replaces the broken Start OAuth button with
       "Sign in to Nous Portal".
- CredentialPoolsOAuthGate: testable helper that routes provider IDs
  to the right OAuth flow based on the overlay table. Closes the
  silent-fail dead-end for openai-codex, qwen-oauth,
  google-gemini-cli, and copilot-acp too — disables the generic
  button with an inline "run hermes auth add <provider> in a
  terminal" hint. PKCE providers (anthropic, etc.) and unknown
  providers still pass through as `.ok` — this gate is strictly
  additive.

Tests: 14 new tests across two suites (NousAuthFlowParserTests,
CredentialPoolsGatingTests). Full suite 120/120 green on top of
v2.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:49:08 +02:00

110 lines
4.1 KiB
Swift

import Testing
import Foundation
@testable import scarf
/// Unit tests for the pure parsers in ``NousAuthFlow``. The subprocess side
/// of the flow is covered by manual end-to-end testing against a live
/// hermes install parser behavior is what we can pin here.
@Suite struct NousAuthFlowParserTests {
// MARK: - Device-code block
@Test func parsesVerificationURLAndUserCode() throws {
let text = """
Requesting device code from Nous Portal...
To continue:
1. Open: https://portal.nousresearch.com/device/ABCD-EFGH
2. If prompted, enter code: ABCD-EFGH
Waiting for approval (polling every 1s)...
"""
let result = try #require(NousAuthFlow.parseDeviceCode(from: text))
#expect(result.verificationURL.absoluteString == "https://portal.nousresearch.com/device/ABCD-EFGH")
#expect(result.userCode == "ABCD-EFGH")
}
@Test func ignoresNoiseBetweenExpectedLines() throws {
// Hermes may log unrelated diagnostics between or after the two
// expected lines. The parser anchors on line-start regex so noise
// above, below, or even intermixed shouldn't block it.
let text = """
[DEBUG] some internal log line
To continue:
1. Open: https://portal.nousresearch.com/device/WXYZ-1234
[DEBUG] another log line
2. If prompted, enter code: WXYZ-1234
extra trailing noise
"""
let result = try #require(NousAuthFlow.parseDeviceCode(from: text))
#expect(result.userCode == "WXYZ-1234")
}
@Test func returnsNilWhenUserCodeLineMissing() {
let text = """
To continue:
1. Open: https://portal.nousresearch.com/device/AAAA-AAAA
Waiting for approval...
"""
#expect(NousAuthFlow.parseDeviceCode(from: text) == nil)
}
@Test func returnsNilWhenURLLineMissing() {
let text = """
To continue:
2. If prompted, enter code: BBBB-BBBB
"""
#expect(NousAuthFlow.parseDeviceCode(from: text) == nil)
}
@Test func returnsNilOnEmptyInput() {
#expect(NousAuthFlow.parseDeviceCode(from: "") == nil)
}
// MARK: - Subscription-required failure
@Test func parsesSubscriptionRequiredBillingURL() throws {
let text = """
Login successful!
Your Nous Portal account does not have an active subscription.
Subscribe here: https://portal.nousresearch.com/billing
After subscribing, run `hermes model` again to finish setup.
"""
let url = try #require(NousAuthFlow.parseSubscriptionRequired(from: text))
#expect(url.absoluteString == "https://portal.nousresearch.com/billing")
}
@Test func subscriptionRequiredReturnsNilWithoutMarker() {
let text = """
hermes: something else went wrong
Subscribe here: https://example.com/billing
"""
// The "Subscribe here:" URL alone isn't enough we require the
// specific subscription-required sentinel so we don't misclassify
// unrelated errors as subscription failures.
#expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil)
}
@Test func subscriptionRequiredReturnsNilWhenBillingURLMissing() {
let text = """
Your Nous Portal account does not have an active subscription.
(no subscribe here line)
"""
#expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil)
}
// MARK: - State equality
@Test func stateEnumEquatableDistinguishesCases() {
let u = URL(string: "https://example.com")!
let a: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u)
let b: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u)
let c: NousAuthFlow.State = .waitingForApproval(userCode: "Y", verificationURL: u)
#expect(a == b)
#expect(a != c)
#expect(NousAuthFlow.State.idle != NousAuthFlow.State.starting)
#expect(NousAuthFlow.State.success != NousAuthFlow.State.failure(reason: "", billingURL: nil))
}
}