pegasus: add full test suite and prometheus CI publishing
This commit is contained in:
parent
385a716516
commit
df18245bc7
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
195
Jenkinsfile
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
100
backend/internal/auth_test.go
Normal file
100
backend/internal/auth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
39
backend/internal/debuglog_test.go
Normal file
39
backend/internal/debuglog_test.go
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
36
backend/internal/fs_test.go
Normal file
36
backend/internal/fs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
92
backend/internal/jellyfin_test.go
Normal file
92
backend/internal/jellyfin_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
50
backend/internal/naming_test.go
Normal file
50
backend/internal/naming_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
86
backend/internal/refresh_test.go
Normal file
86
backend/internal/refresh_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
97
backend/internal/session_test.go
Normal file
97
backend/internal/session_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
92
backend/internal/usermap_test.go
Normal file
92
backend/internal/usermap_test.go
Normal 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
305
backend/main_test.go
Normal 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
13
backend/web/dist/index.html
vendored
Normal 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>
|
||||
2058
frontend/package-lock.json
generated
2058
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
71
frontend/src/App.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
46
frontend/src/Login.test.tsx
Normal file
46
frontend/src/Login.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
77
frontend/src/Uploader.helpers.test.ts
Normal file
77
frontend/src/Uploader.helpers.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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
57
frontend/src/api.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@ -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'],
|
||||
},
|
||||
})
|
||||
|
||||
BIN
scripts/__pycache__/publish_test_metrics.cpython-314.pyc
Normal file
BIN
scripts/__pycache__/publish_test_metrics.cpython-314.pyc
Normal file
Binary file not shown.
253
scripts/publish_test_metrics.py
Executable file
253
scripts/publish_test_metrics.py
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user