pegasus: add full test suite and prometheus CI publishing

This commit is contained in:
Brad Stein 2026-04-10 03:25:23 -03:00
parent 385a716516
commit df18245bc7
29 changed files with 3751 additions and 53 deletions

4
.gitignore vendored
View File

@ -1,5 +1,7 @@
node_modules
frontend/node_modules
frontend/dist
backend/web/dist
build/
backend/web/dist/*
!backend/web/dist/index.html
.git

195
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,195 @@
pipeline {
agent {
kubernetes {
label 'pegasus-tests'
defaultContainer 'go-tester'
yaml """
apiVersion: v1
kind: Pod
spec:
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
containers:
- name: go-tester
image: golang:1.22-bookworm
command: ["cat"]
tty: true
volumeMounts:
- name: workspace-volume
mountPath: /home/jenkins/agent
- name: node-tester
image: node:20-bookworm
command: ["cat"]
tty: true
volumeMounts:
- name: workspace-volume
mountPath: /home/jenkins/agent
- name: publisher
image: python:3.12-slim
command: ["cat"]
tty: true
volumeMounts:
- name: workspace-volume
mountPath: /home/jenkins/agent
volumes:
- name: workspace-volume
emptyDir: {}
"""
}
}
environment {
SUITE_NAME = 'pegasus'
PUSHGATEWAY_URL = 'http://platform-quality-gateway.monitoring.svc.cluster.local:9091'
}
options {
disableConcurrentBuilds()
}
triggers {
pollSCM('H/5 * * * *')
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Backend unit tests') {
steps {
container('go-tester') {
sh '''
set -euo pipefail
mkdir -p build
cd backend
go install github.com/jstemmer/go-junit-report/v2@latest
set +e
go test -coverprofile=../build/coverage-backend.out ./... > ../build/backend-test.out 2>&1
test_rc=$?
set -e
cat ../build/backend-test.out
"$(go env GOPATH)/bin/go-junit-report" < ../build/backend-test.out > ../build/junit-backend.xml
coverage="0"
if [ -f ../build/coverage-backend.out ]; then
coverage="$(go tool cover -func=../build/coverage-backend.out | awk '/^total:/ {gsub("%","",$3); print $3}')"
fi
printf '%s\n' "${coverage}" > ../build/coverage-backend-percent.txt
printf '%s\n' "${test_rc}" > ../build/backend-test.rc
'''
}
}
}
stage('Frontend unit tests') {
steps {
container('node-tester') {
sh '''
set -euo pipefail
mkdir -p build
cd frontend
npm ci
set +e
npm run test:ci > ../build/frontend-test.out 2>&1
test_rc=$?
set -e
cat ../build/frontend-test.out
if [ -f ../build/frontend-coverage/coverage-summary.json ]; then
node -e 'const fs=require("fs");const p=JSON.parse(fs.readFileSync("../build/frontend-coverage/coverage-summary.json","utf8"));const pct=((p.total||{}).lines||{}).pct||0;process.stdout.write(String(pct));' > ../build/coverage-frontend-percent.txt
else
echo "0" > ../build/coverage-frontend-percent.txt
fi
printf '%s\n' "${test_rc}" > ../build/frontend-test.rc
'''
}
}
}
stage('Combine coverage summary') {
steps {
container('publisher') {
sh '''
set -euo pipefail
python - <<'PY'
import json
from pathlib import Path
build = Path('build')
def read_float(path: Path) -> float:
if not path.exists():
return 0.0
try:
return float(path.read_text(encoding='utf-8').strip())
except ValueError:
return 0.0
backend = read_float(build / 'coverage-backend-percent.txt')
frontend = read_float(build / 'coverage-frontend-percent.txt')
combined = (backend + frontend) / 2 if (backend or frontend) else 0.0
(build / 'coverage.json').write_text(
json.dumps(
{
'summary': {
'backend_percent': backend,
'frontend_percent': frontend,
'percent_covered': combined,
}
},
indent=2,
) + '\n',
encoding='utf-8',
)
print(f'Combined Pegasus coverage: {combined:.2f}%')
PY
'''
}
}
}
stage('Publish test metrics') {
steps {
container('publisher') {
sh '''
set -euo pipefail
python scripts/publish_test_metrics.py
'''
}
}
}
stage('Quality gate') {
steps {
container('publisher') {
sh '''
set -euo pipefail
backend_rc="$(cat build/backend-test.rc 2>/dev/null || echo 1)"
frontend_rc="$(cat build/frontend-test.rc 2>/dev/null || echo 1)"
if [ "${backend_rc}" -ne 0 ] || [ "${frontend_rc}" -ne 0 ]; then
echo "Pegasus test suite failed (backend=${backend_rc}, frontend=${frontend_rc})"
exit 1
fi
'''
}
}
}
}
post {
always {
script {
if (fileExists('build/junit-backend.xml') || fileExists('build/junit-frontend.xml')) {
try {
junit allowEmptyResults: true, testResults: 'build/junit-backend.xml,build/junit-frontend.xml'
} catch (Throwable err) {
echo "junit step unavailable: ${err.class.simpleName}"
}
}
}
archiveArtifacts artifacts: 'build/**', allowEmptyArchive: true, fingerprint: true
}
}
}

View File

@ -10,9 +10,13 @@ import (
func CurrentUser(r *http.Request) (Claims, error) {
c, err := r.Cookie(CookieName)
if err != nil { return Claims{}, err }
if err != nil {
return Claims{}, err
}
tok, err := jwt.ParseWithClaims(c.Value, &Claims{}, func(_ *jwt.Token) (any, error) { return sessionKey, nil })
if err != nil { return Claims{}, err }
if err != nil {
return Claims{}, err
}
if cl, ok := tok.Claims.(*Claims); ok && tok.Valid {
return *cl, nil
}

View File

@ -0,0 +1,100 @@
package internal
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func issueSessionCookie(t *testing.T, username, jfToken string) *http.Cookie {
t.Helper()
origKey := sessionKey
sessionKey = []byte("pegasus-test-session-key")
t.Cleanup(func() {
sessionKey = origKey
})
rr := httptest.NewRecorder()
if err := SetSession(rr, username, jfToken); err != nil {
t.Fatalf("SetSession failed: %v", err)
}
res := rr.Result()
defer res.Body.Close()
cookies := res.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
return cookies[0]
}
func TestCurrentUserValidSession(t *testing.T) {
cookie := issueSessionCookie(t, "alice", "jf-token-123")
req := httptest.NewRequest(http.MethodGet, "/api/whoami", nil)
req.AddCookie(cookie)
claims, err := CurrentUser(req)
if err != nil {
t.Fatalf("CurrentUser returned error: %v", err)
}
if claims.Username != "alice" {
t.Fatalf("unexpected username %q", claims.Username)
}
if claims.JFToken != "jf-token-123" {
t.Fatalf("unexpected jellyfin token %q", claims.JFToken)
}
if claims.ExpiresAt == nil || claims.IssuedAt == nil {
t.Fatalf("expected exp+iat claims to be present")
}
}
func TestCurrentUserNoCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
_, err := CurrentUser(req)
if err == nil {
t.Fatalf("expected unauthorized error when cookie is missing")
}
}
func TestCurrentUserInvalidSignature(t *testing.T) {
origKey := sessionKey
sessionKey = []byte("runtime-key")
t.Cleanup(func() {
sessionKey = origKey
})
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
Username: "alice",
JFToken: "bad-token",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
})
signed, err := tok.SignedString([]byte("different-key"))
if err != nil {
t.Fatalf("signing token failed: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: signed})
_, err = CurrentUser(req)
if err == nil {
t.Fatalf("expected signature validation error")
}
}
func TestCurrentUserMalformedToken(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "not-a-jwt"})
_, err := CurrentUser(req)
if err == nil {
t.Fatalf("expected parse error for malformed token")
}
}

View File

@ -12,7 +12,9 @@ var Debug = os.Getenv("PEGASUS_DEBUG") == "1"
var DryRun = os.Getenv("PEGASUS_DRY_RUN") == "1"
func Logf(format string, args ...any) {
if Debug { log.Printf(format, args...) }
if Debug {
log.Printf(format, args...)
}
}
func RedactHeaders(h http.Header) http.Header {

View File

@ -0,0 +1,39 @@
package internal
import (
"net/http"
"reflect"
"testing"
)
func TestRedactHeaders(t *testing.T) {
in := http.Header{
"Cookie": []string{"session=abc"},
"Authorization": []string{"Bearer token"},
"X-Api-Token": []string{"secret"},
"Content-Type": []string{"application/json"},
}
got := RedactHeaders(in)
if want := []string{"<redacted>"}; !reflect.DeepEqual(got["Cookie"], want) {
t.Fatalf("cookie not redacted: %v", got["Cookie"])
}
if want := []string{"<redacted>"}; !reflect.DeepEqual(got["Authorization"], want) {
t.Fatalf("authorization not redacted: %v", got["Authorization"])
}
if want := []string{"<redacted>"}; !reflect.DeepEqual(got["X-Api-Token"], want) {
t.Fatalf("token header not redacted: %v", got["X-Api-Token"])
}
if want := []string{"application/json"}; !reflect.DeepEqual(got["Content-Type"], want) {
t.Fatalf("safe header should remain unchanged: %v", got["Content-Type"])
}
}
func TestLogfNoPanic(t *testing.T) {
orig := Debug
Debug = false
Logf("this should be ignored")
Debug = true
Logf("this should log: %d", 1)
Debug = orig
}

View File

@ -11,8 +11,14 @@ import (
func SafeJoin(root, rel string) (string, error) {
rel = strings.TrimPrefix(rel, "/")
p := filepath.Join(root, rel)
ap, err := filepath.Abs(p); if err != nil { return "", err }
ar, err := filepath.Abs(root); if err != nil { return "", err }
ap, err := filepath.Abs(p)
if err != nil {
return "", err
}
ar, err := filepath.Abs(root)
if err != nil {
return "", err
}
if !strings.HasPrefix(ap, ar+string(os.PathSeparator)) && ap != ar {
return "", errors.New("path escapes root")
}

View File

@ -0,0 +1,36 @@
package internal
import (
"path/filepath"
"testing"
)
func TestSafeJoinWithinRoot(t *testing.T) {
root := t.TempDir()
got, err := SafeJoin(root, "folder/file.txt")
if err != nil {
t.Fatalf("SafeJoin failed: %v", err)
}
want := filepath.Join(root, "folder", "file.txt")
if got != want {
t.Fatalf("SafeJoin mismatch got=%q want=%q", got, want)
}
}
func TestSafeJoinRejectsEscape(t *testing.T) {
root := t.TempDir()
if _, err := SafeJoin(root, "../etc/passwd"); err == nil {
t.Fatalf("expected escape rejection")
}
}
func TestSafeJoinAllowsRoot(t *testing.T) {
root := t.TempDir()
got, err := SafeJoin(root, "")
if err != nil {
t.Fatalf("SafeJoin root failed: %v", err)
}
if got != root {
t.Fatalf("expected root path %q got %q", root, got)
}
}

View File

@ -4,7 +4,7 @@ package internal
import (
"bytes"
"encoding/json"
"fmt" // <-- keep this; used by fmt.Errorf
"fmt" // <-- keep this; used by fmt.Errorf
"net/http"
"os"
"time"

View File

@ -0,0 +1,92 @@
package internal
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestAuthenticateByNameSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/Users/AuthenticateByName" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
if got := r.Header.Get("Content-Type"); got != "application/json" {
t.Fatalf("unexpected content type %q", got)
}
if got := r.Header.Get("Authorization"); !strings.Contains(got, `MediaBrowser Client="Pegasus"`) {
t.Fatalf("unexpected auth header %q", got)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body failed: %v", err)
}
var in map[string]string
if err := json.Unmarshal(body, &in); err != nil {
t.Fatalf("decode body failed: %v", err)
}
if in["Username"] != "brad" || in["Pw"] != "hunter2" {
t.Fatalf("unexpected auth payload %#v", in)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"AccessToken":"abc123","User":{"Id":"u1","Name":"brad"}}`))
}))
defer srv.Close()
j := &Jellyfin{BaseURL: srv.URL, Client: srv.Client()}
out, err := j.AuthenticateByName("brad", "hunter2")
if err != nil {
t.Fatalf("AuthenticateByName failed: %v", err)
}
if out.AccessToken != "abc123" || out.User.Name != "brad" || out.User.Id != "u1" {
t.Fatalf("unexpected auth response %#v", out)
}
}
func TestAuthenticateByNameLoginFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("nope"))
}))
defer srv.Close()
j := &Jellyfin{BaseURL: srv.URL, Client: srv.Client()}
_, err := j.AuthenticateByName("brad", "bad")
if err == nil || !strings.Contains(err.Error(), "login failed") {
t.Fatalf("expected login failed error, got %v", err)
}
}
func TestAuthenticateByNameBadJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("{not-json"))
}))
defer srv.Close()
j := &Jellyfin{BaseURL: srv.URL, Client: srv.Client()}
_, err := j.AuthenticateByName("brad", "pw")
if err == nil {
t.Fatalf("expected decode error")
}
}
func TestAuthenticateByNameRequestError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
baseURL := srv.URL
srv.Close() // force client request error
j := &Jellyfin{BaseURL: baseURL, Client: &http.Client{}}
_, err := j.AuthenticateByName("brad", "pw")
if err == nil {
t.Fatalf("expected transport error")
}
}

View File

@ -11,23 +11,29 @@ import (
var (
descMax = 64
allowedDesc = regexp.MustCompile(`[^A-Za-z0-9 _-]`) // to be replaced by `_`
dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // UI sends YYYY-MM-DD
allowedDesc = regexp.MustCompile(`[^A-Za-z0-9 _-]`) // to be replaced by `_`
dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // UI sends YYYY-MM-DD
finalNameValidate = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`)
)
func SanitizeDesc(in string) string {
s := strings.TrimSpace(in)
if s == "" { s = "upload" }
if s == "" {
s = "upload"
}
s = allowedDesc.ReplaceAllString(s, "_")
s = strings.Join(strings.Fields(s), "_") // collapse whitespace
if len(s) > descMax { s = s[:descMax] }
if len(s) > descMax {
s = s[:descMax]
}
return s
}
func ComposeFinalName(dateStr, desc, origFilename string) (string, error) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), "."))
if ext == "" { ext = "bin" }
if ext == "" {
ext = "bin"
}
var t time.Time
var err error
@ -36,9 +42,13 @@ func ComposeFinalName(dateStr, desc, origFilename string) (string, error) {
} else {
t = time.Now()
}
if err != nil { return "", fmt.Errorf("bad date") }
if err != nil {
return "", fmt.Errorf("bad date")
}
final := fmt.Sprintf("%04d.%02d.%02d.%s.%s", t.Year(), int(t.Month()), t.Day(), SanitizeDesc(desc), ext)
if !finalNameValidate.MatchString(final) { return "", fmt.Errorf("invalid final name") }
if !finalNameValidate.MatchString(final) {
return "", fmt.Errorf("invalid final name")
}
return final, nil
}

View File

@ -0,0 +1,50 @@
package internal
import (
"regexp"
"strings"
"testing"
)
func TestSanitizeDesc(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "upload"},
{" hello world ", "hello_world"},
{"a/b*c", "a_b_c"},
}
for _, tc := range cases {
if got := SanitizeDesc(tc.in); got != tc.want {
t.Fatalf("SanitizeDesc(%q)=%q want=%q", tc.in, got, tc.want)
}
}
long := strings.Repeat("x", 200)
if got := SanitizeDesc(long); len(got) != descMax {
t.Fatalf("expected max len %d, got %d", descMax, len(got))
}
}
func TestComposeFinalName(t *testing.T) {
got, err := ComposeFinalName("2026-04-09", "My desc", "Movie.MP4")
if err != nil {
t.Fatalf("ComposeFinalName returned error: %v", err)
}
if got != "2026.04.09.My_desc.mp4" {
t.Fatalf("unexpected final name %q", got)
}
if _, err := ComposeFinalName("2026-99-01", "desc", "x.mov"); err == nil {
t.Fatalf("expected invalid date error")
}
fallback, err := ComposeFinalName("not-a-date", "desc", "file")
if err != nil {
t.Fatalf("unexpected fallback error: %v", err)
}
if !regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.desc\.bin$`).MatchString(fallback) {
t.Fatalf("unexpected fallback name %q", fallback)
}
}

View File

@ -0,0 +1,86 @@
package internal
import (
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
)
func TestRefreshLibraryDebouncesBurst(t *testing.T) {
defer safeClearTimer()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/Library/Refresh" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
if token := r.Header.Get("X-Emby-Token"); token != "admin-token" {
t.Fatalf("expected API token header, got %q", token)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
t.Setenv("JELLYFIN_URL", srv.URL)
t.Setenv("JELLYFIN_API_KEY", "admin-token")
j := &Jellyfin{Client: srv.Client()}
j.RefreshLibrary("fallback-user-token")
j.RefreshLibrary("fallback-user-token")
j.RefreshLibrary("fallback-user-token")
time.Sleep(2500 * time.Millisecond)
if got := calls.Load(); got != 1 {
t.Fatalf("expected one debounced refresh request, got %d", got)
}
}
func TestRefreshLibrarySkipsWithoutToken(t *testing.T) {
defer safeClearTimer()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
t.Setenv("JELLYFIN_URL", srv.URL)
t.Setenv("JELLYFIN_API_KEY", "")
j := &Jellyfin{Client: srv.Client()}
j.RefreshLibrary("")
time.Sleep(2500 * time.Millisecond)
if got := calls.Load(); got != 0 {
t.Fatalf("expected zero refresh requests without token, got %d", got)
}
}
func TestRefreshLibrarySkipsWithoutURL(t *testing.T) {
defer safeClearTimer()
orig := os.Getenv("JELLYFIN_URL")
if err := os.Unsetenv("JELLYFIN_URL"); err != nil {
t.Fatalf("unset env failed: %v", err)
}
t.Cleanup(func() {
_ = os.Setenv("JELLYFIN_URL", orig)
})
t.Setenv("JELLYFIN_API_KEY", "admin-token")
j := &Jellyfin{Client: &http.Client{Timeout: 200 * time.Millisecond}}
j.RefreshLibrary("fallback-user-token")
time.Sleep(2500 * time.Millisecond)
if refreshTimer != nil {
t.Fatalf("expected timer to self-clear after skip")
}
}

View File

@ -10,8 +10,8 @@ import (
)
type Claims struct {
Username string `json:"u"`
JFToken string `json:"t"`
Username string `json:"u"`
JFToken string `json:"t"`
jwt.RegisteredClaims
}
@ -31,27 +31,29 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
},
})
signed, err := tok.SignedString(sessionKey)
if err != nil { return err }
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: signed,
Path: "/",
HttpOnly: true,
Secure: cookieSecure,
SameSite: http.SameSiteLaxMode,
})
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: signed,
Path: "/",
HttpOnly: true,
Secure: cookieSecure,
SameSite: http.SameSiteLaxMode,
})
return nil
}
func ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Expires: time.Unix(0,0),
MaxAge: -1,
Path: "/",
HttpOnly: true,
Secure: cookieSecure,
SameSite: http.SameSiteLaxMode,
Name: CookieName,
Value: "",
Expires: time.Unix(0, 0),
MaxAge: -1,
Path: "/",
HttpOnly: true,
Secure: cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}

View File

@ -0,0 +1,97 @@
package internal
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/golang-jwt/jwt/v5"
)
func TestSetSessionSetsCookieAndJWTClaims(t *testing.T) {
origKey := sessionKey
origSecure := cookieSecure
sessionKey = []byte("session-test-key")
cookieSecure = false
t.Cleanup(func() {
sessionKey = origKey
cookieSecure = origSecure
})
rr := httptest.NewRecorder()
if err := SetSession(rr, "brad", "jf-access"); err != nil {
t.Fatalf("SetSession failed: %v", err)
}
res := rr.Result()
defer res.Body.Close()
cookies := res.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected one cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != CookieName {
t.Fatalf("unexpected cookie name %q", c.Name)
}
if c.HttpOnly != true {
t.Fatalf("expected HttpOnly cookie")
}
if c.Path != "/" {
t.Fatalf("unexpected cookie path %q", c.Path)
}
if c.Secure {
t.Fatalf("expected insecure cookie when PEGASUS_COOKIE_INSECURE=1 behavior is enabled in test")
}
if c.SameSite != http.SameSiteLaxMode {
t.Fatalf("unexpected SameSite value %v", c.SameSite)
}
tok, err := jwt.ParseWithClaims(c.Value, &Claims{}, func(_ *jwt.Token) (any, error) {
return sessionKey, nil
})
if err != nil {
t.Fatalf("cookie token failed to parse: %v", err)
}
claims, ok := tok.Claims.(*Claims)
if !ok || !tok.Valid {
t.Fatalf("parsed claims invalid")
}
if claims.Username != "brad" || claims.JFToken != "jf-access" {
t.Fatalf("unexpected claims payload: %#v", claims)
}
}
func TestClearSessionExpiresCookie(t *testing.T) {
origSecure := cookieSecure
cookieSecure = true
t.Cleanup(func() {
cookieSecure = origSecure
})
rr := httptest.NewRecorder()
ClearSession(rr)
res := rr.Result()
defer res.Body.Close()
cookies := res.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected one cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != CookieName {
t.Fatalf("unexpected cookie name %q", c.Name)
}
if c.Value != "" {
t.Fatalf("expected cleared cookie value")
}
if c.MaxAge != -1 {
t.Fatalf("expected MaxAge=-1, got %d", c.MaxAge)
}
if !c.Expires.IsZero() && c.Expires.Unix() != 0 {
t.Fatalf("expected unix epoch expiry, got %v", c.Expires)
}
if !c.Secure {
t.Fatalf("expected secure cookie")
}
}

View File

@ -0,0 +1,92 @@
package internal
import (
"os"
"path/filepath"
"reflect"
"testing"
"gopkg.in/yaml.v3"
)
func TestStringOrListUnmarshalScalarAndList(t *testing.T) {
var scalar struct {
Map map[string]StringOrList `yaml:"map"`
}
if err := yaml.Unmarshal([]byte("map:\n alice: photos\n"), &scalar); err != nil {
t.Fatalf("scalar unmarshal failed: %v", err)
}
if got, want := []string(scalar.Map["alice"]), []string{"photos"}; !reflect.DeepEqual(got, want) {
t.Fatalf("scalar mismatch got=%v want=%v", got, want)
}
var list struct {
Map map[string]StringOrList `yaml:"map"`
}
if err := yaml.Unmarshal([]byte("map:\n brad: [movies, shows]\n"), &list); err != nil {
t.Fatalf("list unmarshal failed: %v", err)
}
if got, want := []string(list.Map["brad"]), []string{"movies", "shows"}; !reflect.DeepEqual(got, want) {
t.Fatalf("list mismatch got=%v want=%v", got, want)
}
}
func TestStringOrListUnmarshalInvalidKind(t *testing.T) {
var data struct {
Map map[string]StringOrList `yaml:"map"`
}
err := yaml.Unmarshal([]byte("map:\n nope:\n nested: bad\n"), &data)
if err == nil {
t.Fatalf("expected unmarshal error for mapping node")
}
}
func TestLoadUserMapAndResolve(t *testing.T) {
tempDir := t.TempDir()
mapPath := filepath.Join(tempDir, "user-map.yaml")
content := "map:\n alice: media/photos\n brad: [movies, ' clips ', '', '/', '.']\n"
if err := os.WriteFile(mapPath, []byte(content), 0o644); err != nil {
t.Fatalf("write user map failed: %v", err)
}
m, err := LoadUserMap(mapPath)
if err != nil {
t.Fatalf("LoadUserMap failed: %v", err)
}
root, err := m.Resolve("alice")
if err != nil {
t.Fatalf("Resolve failed: %v", err)
}
if root != filepath.Clean("media/photos") {
t.Fatalf("unexpected resolved root %q", root)
}
roots, err := m.ResolveAll("brad")
if err != nil {
t.Fatalf("ResolveAll failed: %v", err)
}
if got, want := roots, []string{"movies", "clips"}; !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveAll mismatch got=%v want=%v", got, want)
}
}
func TestLoadUserMapMissingFile(t *testing.T) {
_, err := LoadUserMap("/definitely/missing/user-map.yaml")
if err == nil {
t.Fatalf("expected error for missing file")
}
}
func TestResolveAllMissingAndInvalidEntries(t *testing.T) {
m := &UserMap{Map: map[string]StringOrList{
"empty": {"", " ", "/", "."},
}}
if _, err := m.ResolveAll("unknown"); err == nil {
t.Fatalf("expected missing-user error")
}
if _, err := m.ResolveAll("empty"); err == nil {
t.Fatalf("expected invalid-mapping error")
}
}

305
backend/main_test.go Normal file
View File

@ -0,0 +1,305 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"testing"
"time"
tusd "github.com/tus/tusd/pkg/handler"
"scm.bstein.dev/bstein/Pegasus/backend/internal"
)
func TestSanitizeSegment(t *testing.T) {
cases := map[string]string{
"": "",
" Family Videos ": "Family_Videos",
"photos/2026/trip": "photos",
"bad#$name": "bad__name",
"many spaces": "many___spaces",
"../../etc/passwd": "..",
}
for in, want := range cases {
if got := sanitizeSegment(in); got != want {
t.Fatalf("sanitizeSegment(%q)=%q want=%q", in, got, want)
}
}
}
func TestContains(t *testing.T) {
vals := []string{"a", "b", "c"}
if !contains(vals, "b") {
t.Fatalf("expected contains to return true")
}
if contains(vals, "z") {
t.Fatalf("expected contains to return false")
}
}
func TestSanitizeDescriptorAndStemOf(t *testing.T) {
if got := sanitizeDescriptor(" cool.. file$$$ "); got != "cool_file_" {
t.Fatalf("unexpected sanitized descriptor %q", got)
}
if got := sanitizeDescriptor(""); got != "upload" {
t.Fatalf("expected upload fallback, got %q", got)
}
if got := stemOf("My.Video.File.mp4"); got != "My_Video_File" {
t.Fatalf("unexpected stem %q", got)
}
}
func TestComposeFinalName(t *testing.T) {
got := composeFinalName("2026-04-09", "My Desc", "Cool.Movie.MP4")
want := "2026.04.09.My_Desc.Cool_Movie.mp4"
if got != want {
t.Fatalf("composeFinalName got=%q want=%q", got, want)
}
fallback := composeFinalName("not-a-date", "", "file")
if !regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.upload\.file\.bin$`).MatchString(fallback) {
t.Fatalf("unexpected fallback final name %q", fallback)
}
}
func TestCorsForTusHandlesPreflight(t *testing.T) {
h := corsForTus(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next handler should not run for preflight")
}))
req := httptest.NewRequest(http.MethodOptions, "/tus/abc", nil)
req.Header.Set("Origin", "https://pegasus.bstein.dev")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204 for preflight, got %d", rr.Code)
}
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://pegasus.bstein.dev" {
t.Fatalf("unexpected allow-origin %q", got)
}
}
func TestCorsForTusPassesThrough(t *testing.T) {
called := false
h := corsForTus(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusTeapot)
}))
req := httptest.NewRequest(http.MethodGet, "/tus/abc", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if !called {
t.Fatalf("expected wrapped handler to be called")
}
if rr.Code != http.StatusTeapot {
t.Fatalf("expected wrapped response code, got %d", rr.Code)
}
}
func TestSessionRequired(t *testing.T) {
protected := sessionRequired(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Run("unauthorized without cookie", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/tus/", nil)
rr := httptest.NewRecorder()
protected.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
})
t.Run("authorized with session cookie", func(t *testing.T) {
cookieRR := httptest.NewRecorder()
if err := internal.SetSession(cookieRR, "brad", "jf-token"); err != nil {
t.Fatalf("SetSession failed: %v", err)
}
cookies := cookieRR.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("expected session cookie")
}
req := httptest.NewRequest(http.MethodGet, "/tus/", nil)
req.AddCookie(cookies[0])
rr := httptest.NewRecorder()
protected.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rr.Code)
}
})
}
func TestClaimsFromHook(t *testing.T) {
cookieRR := httptest.NewRecorder()
if err := internal.SetSession(cookieRR, "alice", "jf-token"); err != nil {
t.Fatalf("SetSession failed: %v", err)
}
cookies := cookieRR.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("expected session cookie")
}
ev := tusd.HookEvent{
HTTPRequest: tusd.HTTPRequest{Header: http.Header{
"Cookie": []string{cookies[0].Name + "=" + cookies[0].Value},
}},
}
claims, err := claimsFromHook(ev)
if err != nil {
t.Fatalf("claimsFromHook failed: %v", err)
}
if claims.Username != "alice" || claims.JFToken != "jf-token" {
t.Fatalf("unexpected claims %#v", claims)
}
_, err = claimsFromHook(tusd.HookEvent{})
if err == nil {
t.Fatalf("expected missing-header error")
}
}
func TestMoveFromTusMovesFileAndCleansSidecars(t *testing.T) {
origTusDir := tusDir
tusDir = t.TempDir()
t.Cleanup(func() {
tusDir = origTusDir
})
origDryRun := internal.DryRun
internal.DryRun = false
t.Cleanup(func() {
internal.DryRun = origDryRun
})
id := "upload-id-123"
src := filepath.Join(tusDir, id+".bin")
if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
t.Fatalf("write src failed: %v", err)
}
_ = os.WriteFile(filepath.Join(tusDir, id+".info"), []byte("meta"), 0o644)
_ = os.WriteFile(filepath.Join(tusDir, id+".json"), []byte("meta"), 0o644)
dst := filepath.Join(t.TempDir(), "final", "file.bin")
ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}
if err := moveFromTus(ev, dst); err != nil {
t.Fatalf("moveFromTus failed: %v", err)
}
b, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("expected moved file at destination: %v", err)
}
if string(b) != "payload" {
t.Fatalf("unexpected destination payload %q", string(b))
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("expected source file to be removed, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(tusDir, id+".info")); !os.IsNotExist(err) {
t.Fatalf("expected .info sidecar cleanup, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(tusDir, id+".json")); !os.IsNotExist(err) {
t.Fatalf("expected .json sidecar cleanup, stat err=%v", err)
}
}
func TestMoveFromTusDryRun(t *testing.T) {
origTusDir := tusDir
tusDir = t.TempDir()
t.Cleanup(func() {
tusDir = origTusDir
})
origDryRun := internal.DryRun
internal.DryRun = true
t.Cleanup(func() {
internal.DryRun = origDryRun
})
id := "upload-id-dry"
src := filepath.Join(tusDir, id)
if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
t.Fatalf("write src failed: %v", err)
}
dst := filepath.Join(t.TempDir(), "dst.bin")
ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}
if err := moveFromTus(ev, dst); err != nil {
t.Fatalf("moveFromTus dry-run failed: %v", err)
}
if _, err := os.Stat(src); err != nil {
t.Fatalf("expected source file to remain in dry-run mode: %v", err)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Fatalf("expected destination to be absent in dry-run mode")
}
}
func TestMoveFromTusMissingDataFile(t *testing.T) {
origTusDir := tusDir
tusDir = t.TempDir()
t.Cleanup(func() {
tusDir = origTusDir
})
ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: "missing"}}
err := moveFromTus(ev, filepath.Join(t.TempDir(), "dst.bin"))
if err == nil {
t.Fatalf("expected not-found error")
}
}
func TestWriteJSON(t *testing.T) {
rr := httptest.NewRecorder()
writeJSON(rr, map[string]any{"ok": true})
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("unexpected content-type %q", ct)
}
if rr.Code != http.StatusOK {
t.Fatalf("unexpected status %d", rr.Code)
}
body, _ := io.ReadAll(rr.Result().Body)
if !regexp.MustCompile(`"ok"\s*:\s*true`).Match(body) {
t.Fatalf("unexpected JSON body %q", string(body))
}
}
func TestEnvHelper(t *testing.T) {
t.Setenv("PEGASUS_TEST_ENV", "value")
if got := env("PEGASUS_TEST_ENV", "fallback"); got != "value" {
t.Fatalf("unexpected env result %q", got)
}
if got := env("PEGASUS_TEST_ENV_MISSING", "fallback"); got != "fallback" {
t.Fatalf("unexpected fallback env result %q", got)
}
}
func TestLoggingRWWriteHeader(t *testing.T) {
rr := httptest.NewRecorder()
lw := &loggingRW{ResponseWriter: rr, status: http.StatusOK}
lw.WriteHeader(http.StatusCreated)
if lw.status != http.StatusCreated {
t.Fatalf("expected tracked status %d, got %d", http.StatusCreated, lw.status)
}
if rr.Code != http.StatusCreated {
t.Fatalf("expected recorder status %d, got %d", http.StatusCreated, rr.Code)
}
}
func TestComposeFinalNameDateFallbackNotEmpty(t *testing.T) {
got := composeFinalName("", "clip", "v.mkv")
today := time.Now().Format("2006.01.02")
if got[:10] != today {
t.Fatalf("expected date prefix %s got %s", today, got[:10])
}
}

13
backend/web/dist/index.html vendored Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pegasus</title>
</head>
<body>
<main>
<h1>Pegasus frontend assets are not bundled in this build.</h1>
</main>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5173"
"preview": "vite preview --port 5173",
"test": "vitest run",
"test:ci": "vitest run --reporter=default --reporter=junit --outputFile=../build/junit-frontend.xml --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=json-summary --coverage.reportsDirectory=../build/frontend-coverage"
},
"dependencies": {
"@picocss/pico": "^2.1.1",
@ -15,10 +17,15 @@
"tus-js-client": "^4.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^27.0.0",
"typescript": "^5.9.2",
"vite": "^7.1.5"
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}

71
frontend/src/App.test.tsx Normal file
View File

@ -0,0 +1,71 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import { api } from './api'
vi.mock('./api', () => ({
api: vi.fn(),
}))
vi.mock('./Uploader', () => ({
default: function MockUploader() {
return <div data-testid="uploader">uploader</div>
},
}))
vi.mock('./Login', () => ({
default: function MockLogin({ onLogin }: { onLogin: () => void }) {
return (
<button type="button" onClick={onLogin}>
mock-login
</button>
)
},
}))
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('location', { reload: vi.fn() } as any)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders uploader when whoami is successful', async () => {
const apiMock = vi.mocked(api)
apiMock.mockResolvedValueOnce({ username: 'brad' } as never)
render(<App />)
expect(await screen.findByTestId('uploader')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument()
expect(apiMock).toHaveBeenCalledWith('/api/whoami')
})
it('renders login when whoami fails', async () => {
const apiMock = vi.mocked(api)
apiMock.mockRejectedValueOnce(new Error('unauthorized'))
render(<App />)
expect(await screen.findByRole('button', { name: 'mock-login' })).toBeInTheDocument()
})
it('calls logout endpoint and reloads page', async () => {
const apiMock = vi.mocked(api)
apiMock.mockResolvedValueOnce({ username: 'brad' } as never)
apiMock.mockResolvedValueOnce({ ok: true } as never)
render(<App />)
const logoutBtn = await screen.findByRole('button', { name: 'Logout' })
fireEvent.click(logoutBtn)
await waitFor(() => {
expect(apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
})
expect((globalThis.location as any).reload).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,46 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Login from './Login'
import { api } from './api'
vi.mock('./api', () => ({
api: vi.fn(),
}))
describe('Login', () => {
it('submits credentials and calls onLogin', async () => {
const apiMock = vi.mocked(api)
apiMock.mockResolvedValue({ ok: true })
const onLogin = vi.fn()
render(<Login onLogin={onLogin} />)
fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'brad' } })
fireEvent.change(screen.getByPlaceholderText('password'), { target: { value: 'pass' } })
fireEvent.click(screen.getByRole('button', { name: 'Login' }))
await waitFor(() => {
expect(onLogin).toHaveBeenCalledTimes(1)
})
expect(apiMock).toHaveBeenCalledWith('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'brad', password: 'pass' }),
})
})
it('shows server error when login fails', async () => {
const apiMock = vi.mocked(api)
apiMock.mockRejectedValue(new Error('invalid credentials'))
render(<Login onLogin={() => {}} />)
fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'brad' } })
fireEvent.change(screen.getByPlaceholderText('password'), { target: { value: 'bad' } })
fireEvent.click(screen.getByRole('button', { name: 'Login' }))
expect(await screen.findByText('invalid credentials')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,77 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import {
clampOneLevel,
composeName,
extOf,
isDetailedError,
isImageFile,
isLikelyMobileUA,
isVideoFile,
normalizeRows,
sanitizeDesc,
sanitizeFolderName,
stemOf,
} from './Uploader'
describe('Uploader helpers', () => {
let logSpy: ReturnType<typeof vi.spyOn>
beforeAll(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterAll(() => {
logSpy.mockRestore()
})
it('sanitizes description and folder names', () => {
expect(sanitizeDesc(' Hello world! ')).toBe('Hello_world_')
expect(sanitizeDesc('')).toBe('upload')
expect(sanitizeFolderName(' /my folder/with/depth ')).toBe('my_folder')
expect(sanitizeFolderName('bad#$name')).toBe('bad_name')
})
it('computes extension, stem, and composed final name', () => {
expect(extOf('clip.MP4')).toBe('mp4')
expect(extOf('noext')).toBe('')
expect(stemOf('My.Video.File.mp4')).toBe('My_Video_File')
expect(composeName('2026-04-09', 'Trip Clip', 'My.Video.File.mp4')).toBe(
'2026.04.09.Trip_Clip.My_Video_File.mp4',
)
})
it('clamps one-level paths and normalizes row payload shapes', () => {
expect(clampOneLevel('/photos/2026/trip/')).toBe('photos')
expect(clampOneLevel('')).toBe('')
const rows = normalizeRows([
{ Name: 'a', Path: 'a', IsDir: true, Size: 12, Mtime: 55 },
{ name: 'b', path: 'b', is_dir: false, size: 3, mtime: 99 },
null,
] as any)
expect(rows[0]).toEqual({ name: 'a', path: 'a', is_dir: true, size: 12, mtime: 55 })
expect(rows[1]).toEqual({ name: 'b', path: 'b', is_dir: false, size: 3, mtime: 99 })
expect(rows[2]).toEqual({ name: '', path: '', is_dir: false, size: 0, mtime: 0 })
})
it('detects video/image files by type and extension', () => {
const mkFile = (name: string, type: string) => new File(['x'], name, { type })
expect(isVideoFile(mkFile('x.mp4', 'video/mp4'))).toBe(true)
expect(isVideoFile(mkFile('x.mkv', ''))).toBe(true)
expect(isVideoFile(mkFile('x.txt', 'text/plain'))).toBe(false)
expect(isImageFile(mkFile('x.jpg', 'image/jpeg'))).toBe(true)
expect(isImageFile(mkFile('x.heic', ''))).toBe(true)
expect(isImageFile(mkFile('x.mp4', 'video/mp4'))).toBe(false)
})
it('identifies detailed tus errors and mobile UA heuristics', () => {
expect(isDetailedError({ originalRequest: {} })).toBe(true)
expect(isDetailedError({ originalResponse: {} })).toBe(true)
expect(isDetailedError({})).toBe(false)
expect(typeof isLikelyMobileUA()).toBe('boolean')
})
})

View File

@ -6,31 +6,29 @@ import * as tus from 'tus-js-client'
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
// ---------- helpers ----------
function sanitizeDesc(s: string) {
export function sanitizeDesc(s: string) {
s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_')
if (!s) s = 'upload'
return s.slice(0, 64)
}
function sanitizeFolderName(s: string) {
export function sanitizeFolderName(s: string) {
// 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-]
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
return s.slice(0, 64)
}
function extOf(n: string) {
export function extOf(n: string) {
const i = n.lastIndexOf('.')
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
}
function stemOf(n: string) {
export function stemOf(n: string) {
const i = n.lastIndexOf('.')
const stem = i > -1 ? n.slice(0, i) : n
// keep safe charset and avoid extra dots within segments
return sanitizeDesc(stem.replace(/\./g, '_')) || 'file'
}
function composeName(date: string, desc: string, orig: string) {
export function composeName(date: string, desc: string, orig: string) {
const d = date || new Date().toISOString().slice(0, 10)
const [Y, M, D] = d.split('-')
const sDesc = sanitizeDesc(desc)
@ -39,11 +37,11 @@ function composeName(date: string, desc: string, orig: string) {
// New pattern: YYYY.MM.DD.Description.OriginalStem.ext
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
}
function clampOneLevel(p: string) {
export function clampOneLevel(p: string) {
if (!p) return ''
return p.replace(/^\/+|\/+$/g, '').split('/')[0] || ''
}
function normalizeRows(listRaw: any[]): FileRow[] {
export function normalizeRows(listRaw: any[]): FileRow[] {
return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({
name: r?.name ?? r?.Name ?? '',
path: r?.path ?? r?.Path ?? '',
@ -55,14 +53,14 @@ function normalizeRows(listRaw: any[]): FileRow[] {
const videoExt = new Set(['mp4', 'mkv', 'mov', 'avi', 'm4v', 'webm', 'mpg', 'mpeg', 'ts', 'm2ts'])
const imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff'])
const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name))
const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name))
export const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name))
export const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name))
function isDetailedError(e: unknown): e is tus.DetailedError {
export function isDetailedError(e: unknown): e is tus.DetailedError {
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
}
function isLikelyMobileUA(): boolean {
export function isLikelyMobileUA(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent || ''
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches

57
frontend/src/api.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from './api'
describe('api helper', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns parsed json when content-type is json', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
const res = await api<{ ok: boolean }>('/api/healthz')
expect(res.ok).toBe(true)
expect(fetchMock).toHaveBeenCalledWith('/api/healthz', { credentials: 'include' })
})
it('parses json from text payload when response header is not json', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('{"value":42}', {
status: 200,
headers: { 'content-type': 'text/plain' },
}),
)
const res = await api<{ value: number }>('/api/text-json')
expect(res.value).toBe(42)
})
it('returns raw text when text is not json', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('hello', {
status: 200,
headers: { 'content-type': 'text/plain' },
}),
)
const res = await api<string>('/api/text')
expect(res).toBe('hello')
})
it('throws server message when response is not ok', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('invalid credentials', {
status: 401,
headers: { 'content-type': 'text/plain' },
}),
)
await expect(api('/api/login')).rejects.toThrow('invalid credentials')
})
})

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

View File

@ -1,5 +1,5 @@
// frontend/vite.config.ts
import { defineConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
@ -8,4 +8,11 @@ export default defineConfig({
outDir: 'dist',
emptyOutDir: true,
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
css: true,
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
})

253
scripts/publish_test_metrics.py Executable file
View File

@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Publish Pegasus test-suite results to Prometheus via Pushgateway.
Inputs:
- Backend JUnit XML and frontend JUnit XML
- Backend/frontend coverage summaries
Outputs pushed:
- platform_quality_gate_runs_total{suite="pegasus",status="ok|failed"}
- pegasus_quality_gate_tests_total{suite="pegasus",result=*}
- pegasus_quality_gate_coverage_percent{suite="pegasus"}
"""
from __future__ import annotations
import json
import os
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
def _escape_label(value: str) -> str:
return value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
def _label_str(labels: dict[str, str]) -> str:
parts = [f'{key}="{_escape_label(val)}"' for key, val in labels.items() if val]
return "{" + ",".join(parts) + "}" if parts else ""
def _read_text(path: Path) -> str:
if not path.exists():
return ""
return path.read_text(encoding="utf-8")
def _as_int(node: ET.Element, name: str) -> int:
raw = node.attrib.get(name) or "0"
try:
return int(float(raw))
except ValueError:
return 0
def _load_junit(path: Path) -> dict[str, int]:
if not path.exists():
return {"tests": 0, "failures": 0, "errors": 0, "skipped": 0}
tree = ET.parse(path)
root = tree.getroot()
suites: list[ET.Element]
if root.tag == "testsuite":
suites = [root]
elif root.tag == "testsuites":
suites = list(root.findall("testsuite"))
else:
suites = []
totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0}
for suite in suites:
totals["tests"] += _as_int(suite, "tests")
totals["failures"] += _as_int(suite, "failures")
totals["errors"] += _as_int(suite, "errors")
totals["skipped"] += _as_int(suite, "skipped")
return totals
def _load_backend_coverage_percent(path: Path) -> float:
if not path.exists():
return 0.0
try:
return float(path.read_text(encoding="utf-8").strip())
except ValueError:
return 0.0
def _load_frontend_coverage_percent(path: Path) -> float:
if not path.exists():
return 0.0
payload = json.loads(path.read_text(encoding="utf-8"))
total = payload.get("total") or {}
lines = total.get("lines") or {}
pct = lines.get("pct")
if isinstance(pct, (int, float)):
return float(pct)
return 0.0
def _read_test_exit_code(path: Path) -> int:
if not path.exists():
return 1
raw = path.read_text(encoding="utf-8").strip()
try:
return int(raw)
except ValueError:
return 1
def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, str]) -> float:
text = _read_http(f"{pushgateway_url.rstrip('/')}/metrics")
if not text:
return 0.0
for line in text.splitlines():
if not line.startswith(metric + "{"):
continue
if any(f'{k}="{v}"' not in line for k, v in labels.items()):
continue
parts = line.split()
if len(parts) < 2:
continue
try:
return float(parts[1])
except ValueError:
return 0.0
return 0.0
def _read_http(url: str) -> str:
try:
with urllib.request.urlopen(url, timeout=10) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception:
return ""
def _post_text(url: str, payload: str) -> None:
req = urllib.request.Request(
url,
data=payload.encode("utf-8"),
method="POST",
headers={"Content-Type": "text/plain"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status >= 400:
raise RuntimeError(f"push failed status={resp.status}")
def main() -> int:
suite = os.getenv("SUITE_NAME", "pegasus")
pushgateway_url = os.getenv(
"PUSHGATEWAY_URL", "http://platform-quality-gateway.monitoring.svc.cluster.local:9091"
)
backend_junit = Path(os.getenv("BACKEND_JUNIT_XML", "build/junit-backend.xml"))
frontend_junit = Path(os.getenv("FRONTEND_JUNIT_XML", "build/junit-frontend.xml"))
backend_cov = Path(os.getenv("BACKEND_COVERAGE_PERCENT_FILE", "build/coverage-backend-percent.txt"))
frontend_cov = Path(
os.getenv("FRONTEND_COVERAGE_JSON", "build/frontend-coverage/coverage-summary.json")
)
backend_rc_file = Path(os.getenv("BACKEND_TEST_RC_FILE", "build/backend-test.rc"))
frontend_rc_file = Path(os.getenv("FRONTEND_TEST_RC_FILE", "build/frontend-test.rc"))
b = _load_junit(backend_junit)
f = _load_junit(frontend_junit)
totals = {
"tests": b["tests"] + f["tests"],
"failures": b["failures"] + f["failures"],
"errors": b["errors"] + f["errors"],
"skipped": b["skipped"] + f["skipped"],
}
passed = max(totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"], 0)
backend_pct = _load_backend_coverage_percent(backend_cov)
frontend_pct = _load_frontend_coverage_percent(frontend_cov)
coverage_pct = (backend_pct + frontend_pct) / 2 if (backend_pct or frontend_pct) else 0.0
backend_rc = _read_test_exit_code(backend_rc_file)
frontend_rc = _read_test_exit_code(frontend_rc_file)
outcome = (
"ok"
if backend_rc == 0
and frontend_rc == 0
and totals["tests"] > 0
and totals["failures"] == 0
and totals["errors"] == 0
else "failed"
)
job_name = "platform-quality-ci"
ok_count = _fetch_existing_counter(
pushgateway_url,
"platform_quality_gate_runs_total",
{"job": job_name, "suite": suite, "status": "ok"},
)
failed_count = _fetch_existing_counter(
pushgateway_url,
"platform_quality_gate_runs_total",
{"job": job_name, "suite": suite, "status": "failed"},
)
if outcome == "ok":
ok_count += 1
else:
failed_count += 1
branch = os.getenv("BRANCH_NAME", "")
build_number = os.getenv("BUILD_NUMBER", "")
commit = os.getenv("GIT_COMMIT", "")
labels = {
"suite": suite,
"branch": branch,
"build_number": build_number,
"commit": commit,
}
payload_lines = [
"# TYPE platform_quality_gate_runs_total counter",
f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count:.0f}',
f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count:.0f}',
"# TYPE pegasus_quality_gate_tests_total gauge",
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="passed"}} {passed}',
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}',
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="error"}} {totals["errors"]}',
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}',
"# TYPE pegasus_quality_gate_coverage_percent gauge",
f'pegasus_quality_gate_coverage_percent{{suite="{suite}"}} {coverage_pct:.3f}',
"# TYPE pegasus_quality_gate_build_info gauge",
f"pegasus_quality_gate_build_info{_label_str(labels)} 1",
]
payload = "\n".join(payload_lines) + "\n"
push_url = f"{pushgateway_url.rstrip('/')}/metrics/job/{job_name}/suite/{suite}"
_post_text(push_url, payload)
summary = {
"suite": suite,
"tests_total": totals["tests"],
"tests_passed": passed,
"tests_failed": totals["failures"],
"tests_errors": totals["errors"],
"tests_skipped": totals["skipped"],
"coverage_percent": round(coverage_pct, 3),
"outcome": outcome,
"backend_rc": backend_rc,
"frontend_rc": frontend_rc,
"ok_counter": ok_count,
"failed_counter": failed_count,
}
Path("build/metrics-summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"metrics push failed: {exc}")
raise