mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
237 lines
8.6 KiB
Go
237 lines
8.6 KiB
Go
//go:build unix
|
|
|
|
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/application"
|
|
"github.com/matthewlu070111/smart-srun/core/internal/portal"
|
|
)
|
|
|
|
type wizardLine struct{ *http.Client }
|
|
|
|
func (wizardLine) SourceAddr() netip.Addr { return netip.MustParseAddr("192.0.2.3") }
|
|
|
|
func wizardService(t *testing.T, handler http.HandlerFunc) (*running, VerificationParams) {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
service := start(t, func(o *Options) {
|
|
o.OpenProbe = func(context.Context, string) (portal.Fetcher, func(), error) {
|
|
return wizardLine{server.Client()}, func() {}, nil
|
|
}
|
|
})
|
|
return service, VerificationParams{DetectACIDParams: DetectACIDParams{BaseURL: server.URL + "/login", ACID: "9", Iface: "wan", AccessMode: "wired", IdempotencyKey: "wizard", Session: "wizard-session"}, UserID: "student"}
|
|
}
|
|
|
|
func wizardResult(t *testing.T, service *running, method string, p VerificationParams) (SubmitResult, VerificationResult, application.Action) {
|
|
t.Helper()
|
|
var receipt SubmitResult
|
|
if err := json.Unmarshal(service.call(method, p), &receipt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
action := service.awaitTerminal(receipt.ActionID)
|
|
var view struct {
|
|
Result VerificationResult `json:"result"`
|
|
}
|
|
raw := service.call("action.get", ActionParams{ActionID: receipt.ActionID, Session: p.Session})
|
|
if err := json.Unmarshal(raw, &view); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if action.Request.PrivateJSON != "" || strings.Contains(string(raw), "draft-secret") {
|
|
t.Fatal("task exposed private draft")
|
|
}
|
|
if _, err := os.Stat(service.paths.ConfigFile()); !os.IsNotExist(err) {
|
|
t.Fatal("probe wrote saved configuration")
|
|
}
|
|
return receipt, view.Result, action
|
|
}
|
|
|
|
func TestWizardOnlineIdentityNeverAuthenticates(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name, response string
|
|
confirmed bool
|
|
suffix string
|
|
}{
|
|
{"realm", `{"user_name":"student","domain":"Custom.REALM"}`, true, "Custom.REALM"},
|
|
{"bare", `{"user_name":"student"}`, false, ""},
|
|
{"empty_realm", `{"user_name":"student@"}`, false, ""},
|
|
{"foreign", `{"user_name":"other@cmcc"}`, false, ""},
|
|
{"offline", `{"error":"not_online"}`, false, ""},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var calls atomic.Int32
|
|
service, p := wizardService(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
if r.URL.Path != "/cgi-bin/rad_user_info" || r.URL.Query().Get("password") != "" {
|
|
t.Error("identity attempted credentialed request")
|
|
}
|
|
io.WriteString(w, test.response)
|
|
})
|
|
_, result, action := wizardResult(t, service, "detect.identity", p)
|
|
if action.State != application.StateSucceeded || result.Confirmed != test.confirmed || result.Suffix != test.suffix || result.PasswordVerified || calls.Load() != 1 {
|
|
t.Fatalf("identity: %+v / %s", result, action.State)
|
|
}
|
|
if test.name != "offline" {
|
|
p.IdempotencyKey = "active"
|
|
p.Password = "draft-secret"
|
|
p.Candidates = []string{"ctcc"}
|
|
_, result, action = wizardResult(t, service, "detect.verify", p)
|
|
if action.State != application.StateSucceeded || len(result.Attempts) != 0 || result.PasswordVerified || calls.Load() != 2 {
|
|
t.Fatal("online session was touched")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWizardVerificationRequiresExplicitCandidateAndExactIdentity(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name, login, reported, outcome string
|
|
confirmed, verified bool
|
|
}{
|
|
{"accepted", `{"error":"ok"}`, "student@custom", "hit", true, true},
|
|
{"bare_is_insufficient", `{"error":"ok"}`, "student", "other", false, false},
|
|
{"already_online", `{"error":"ip_already_online_error"}`, "student@custom", "hit", true, false},
|
|
{"bad_password", `{"error":"login_error","error_msg":"password error"}`, "", "credential", false, false},
|
|
{"limited", `{"error":"E2532"}`, "", "limited", false, false},
|
|
{"unknown", `{"error":"backend_busy","error_msg":"draft-secret"}`, "", "other", false, false},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var online, login atomic.Int32
|
|
service, p := wizardService(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/cgi-bin/rad_user_info":
|
|
if online.Add(1) == 1 {
|
|
io.WriteString(w, `{"error":"not_online"}`)
|
|
} else {
|
|
json.NewEncoder(w).Encode(map[string]string{"user_name": test.reported})
|
|
}
|
|
case "/cgi-bin/get_challenge":
|
|
io.WriteString(w, `{"challenge":"synthetic-token","client_ip":"192.0.2.3"}`)
|
|
case "/cgi-bin/srun_portal":
|
|
login.Add(1)
|
|
q := r.URL.Query()
|
|
if q.Get("action") != "login" || q.Get("username") != "student@custom" || !strings.HasPrefix(q.Get("password"), "{MD5}") || q.Get("ac_id") != "9" {
|
|
t.Error("incorrect authentication wire fields")
|
|
}
|
|
io.WriteString(w, test.login)
|
|
default:
|
|
t.Error("unexpected request, including logout", r.URL.Path)
|
|
w.WriteHeader(500)
|
|
}
|
|
})
|
|
p.Password = "draft-secret"
|
|
p.Candidates = []string{"custom", "unused"}
|
|
receipt, result, action := wizardResult(t, service, "detect.verify", p)
|
|
if action.State != application.StateSucceeded || result.Confirmed != test.confirmed || result.PasswordVerified != test.verified || len(result.Attempts) != 1 || result.Attempts[0].Outcome != test.outcome || login.Load() != 1 {
|
|
t.Fatalf("verification: %+v / %s", result, action.State)
|
|
}
|
|
var again SubmitResult
|
|
json.Unmarshal(service.call("detect.verify", p), &again)
|
|
if !again.Duplicate || again.ActionID != receipt.ActionID {
|
|
t.Fatal("completed duplicate authenticated again")
|
|
}
|
|
p.Password = "changed-secret"
|
|
service.callExpectingError("detect.verify", p)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWizardInvalidOrUnknownInputNeverAuthenticates(t *testing.T) {
|
|
var calls atomic.Int32
|
|
service, p := wizardService(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
if r.URL.Path != "/cgi-bin/rad_user_info" {
|
|
t.Error("unknown preflight allowed login")
|
|
}
|
|
io.WriteString(w, `{"error":"backend_busy"}`)
|
|
})
|
|
p.Password = "draft-secret"
|
|
p.Candidates = []string{""}
|
|
service.callExpectingError("detect.identity", p)
|
|
_, _, action := wizardResult(t, service, "detect.verify", p)
|
|
if action.State != application.StateFailed || calls.Load() != 1 {
|
|
t.Fatal("unknown status must stop")
|
|
}
|
|
p.IdempotencyKey = "too-many"
|
|
p.Candidates = []string{"a", "b", "c", "d", "e", "f"}
|
|
service.callExpectingError("detect.verify", p)
|
|
p.Candidates = []string{"??"}
|
|
service.callExpectingError("detect.verify", p)
|
|
p.Candidates = []string{""}
|
|
p.Iface = ""
|
|
service.callExpectingError("detect.verify", p)
|
|
if calls.Load() != 1 {
|
|
t.Fatal("invalid input performed I/O")
|
|
}
|
|
}
|
|
|
|
func TestWizardOnlyRetriesExplicitMissAndCancellationStopsNextCandidate(t *testing.T) {
|
|
for _, cancel := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "next_empty_suffix", true: "cancel_during_cooldown"}[cancel], func(t *testing.T) {
|
|
var logins, online atomic.Int32
|
|
first := make(chan struct{})
|
|
service, p := wizardService(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/cgi-bin/rad_user_info":
|
|
if online.Add(1) == 1 {
|
|
io.WriteString(w, `{"error":"not_online"}`)
|
|
} else {
|
|
io.WriteString(w, `{"user_name":"student"}`)
|
|
}
|
|
case "/cgi-bin/get_challenge":
|
|
io.WriteString(w, `{"challenge":"synthetic-token"}`)
|
|
case "/cgi-bin/srun_portal":
|
|
if logins.Add(1) == 1 {
|
|
io.WriteString(w, `{"error":"E2531"}`)
|
|
close(first)
|
|
} else {
|
|
if r.URL.Query().Get("username") != "student" {
|
|
t.Error("explicit empty suffix lost")
|
|
}
|
|
io.WriteString(w, `{"error":"ok"}`)
|
|
}
|
|
default:
|
|
t.Error("unexpected request", r.URL.Path)
|
|
}
|
|
})
|
|
p.Password = "draft-secret"
|
|
p.Candidates = []string{"missing", "missing", ""}
|
|
var receipt SubmitResult
|
|
json.Unmarshal(service.call("detect.verify", p), &receipt)
|
|
select {
|
|
case <-first:
|
|
case <-time.After(patience):
|
|
t.Fatal("no login")
|
|
}
|
|
if cancel {
|
|
service.call("action.cancel", ActionParams{ActionID: receipt.ActionID, Session: p.Session})
|
|
}
|
|
action := service.awaitTerminal(receipt.ActionID)
|
|
if cancel {
|
|
if action.State != application.StateCancelled || action.ResultJSON != "" || logins.Load() != 1 {
|
|
t.Fatal("cancel authenticated next candidate")
|
|
}
|
|
} else {
|
|
var result VerificationResult
|
|
json.Unmarshal([]byte(action.ResultJSON), &result)
|
|
if action.State != application.StateSucceeded || !result.Confirmed || result.Suffix != "" || !result.PasswordVerified || len(result.Attempts) != 2 || logins.Load() != 2 {
|
|
t.Fatalf("explicit candidate fallback: %+v", result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|