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
|
node_modules
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
frontend/dist
|
frontend/dist
|
||||||
backend/web/dist
|
build/
|
||||||
|
backend/web/dist/*
|
||||||
|
!backend/web/dist/index.html
|
||||||
.git
|
.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) {
|
func CurrentUser(r *http.Request) (Claims, error) {
|
||||||
c, err := r.Cookie(CookieName)
|
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 })
|
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 {
|
if cl, ok := tok.Claims.(*Claims); ok && tok.Valid {
|
||||||
return *cl, nil
|
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"
|
var DryRun = os.Getenv("PEGASUS_DRY_RUN") == "1"
|
||||||
|
|
||||||
func Logf(format string, args ...any) {
|
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 {
|
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) {
|
func SafeJoin(root, rel string) (string, error) {
|
||||||
rel = strings.TrimPrefix(rel, "/")
|
rel = strings.TrimPrefix(rel, "/")
|
||||||
p := filepath.Join(root, rel)
|
p := filepath.Join(root, rel)
|
||||||
ap, err := filepath.Abs(p); if err != nil { return "", err }
|
ap, err := filepath.Abs(p)
|
||||||
ar, err := filepath.Abs(root); if err != nil { return "", err }
|
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 {
|
if !strings.HasPrefix(ap, ar+string(os.PathSeparator)) && ap != ar {
|
||||||
return "", errors.New("path escapes root")
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt" // <-- keep this; used by fmt.Errorf
|
"fmt" // <-- keep this; used by fmt.Errorf
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"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 (
|
var (
|
||||||
descMax = 64
|
descMax = 64
|
||||||
allowedDesc = regexp.MustCompile(`[^A-Za-z0-9 _-]`) // to be replaced by `_`
|
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
|
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}$`)
|
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 {
|
func SanitizeDesc(in string) string {
|
||||||
s := strings.TrimSpace(in)
|
s := strings.TrimSpace(in)
|
||||||
if s == "" { s = "upload" }
|
if s == "" {
|
||||||
|
s = "upload"
|
||||||
|
}
|
||||||
s = allowedDesc.ReplaceAllString(s, "_")
|
s = allowedDesc.ReplaceAllString(s, "_")
|
||||||
s = strings.Join(strings.Fields(s), "_") // collapse whitespace
|
s = strings.Join(strings.Fields(s), "_") // collapse whitespace
|
||||||
if len(s) > descMax { s = s[:descMax] }
|
if len(s) > descMax {
|
||||||
|
s = s[:descMax]
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func ComposeFinalName(dateStr, desc, origFilename string) (string, error) {
|
func ComposeFinalName(dateStr, desc, origFilename string) (string, error) {
|
||||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), "."))
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), "."))
|
||||||
if ext == "" { ext = "bin" }
|
if ext == "" {
|
||||||
|
ext = "bin"
|
||||||
|
}
|
||||||
|
|
||||||
var t time.Time
|
var t time.Time
|
||||||
var err error
|
var err error
|
||||||
@ -36,9 +42,13 @@ func ComposeFinalName(dateStr, desc, origFilename string) (string, error) {
|
|||||||
} else {
|
} else {
|
||||||
t = time.Now()
|
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)
|
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
|
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 {
|
type Claims struct {
|
||||||
Username string `json:"u"`
|
Username string `json:"u"`
|
||||||
JFToken string `json:"t"`
|
JFToken string `json:"t"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,27 +31,29 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
signed, err := tok.SignedString(sessionKey)
|
signed, err := tok.SignedString(sessionKey)
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
http.SetCookie(w, &http.Cookie{
|
return err
|
||||||
Name: CookieName,
|
}
|
||||||
Value: signed,
|
http.SetCookie(w, &http.Cookie{
|
||||||
Path: "/",
|
Name: CookieName,
|
||||||
HttpOnly: true,
|
Value: signed,
|
||||||
Secure: cookieSecure,
|
Path: "/",
|
||||||
SameSite: http.SameSiteLaxMode,
|
HttpOnly: true,
|
||||||
})
|
Secure: cookieSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ClearSession(w http.ResponseWriter) {
|
func ClearSession(w http.ResponseWriter) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: CookieName,
|
Name: CookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Expires: time.Unix(0,0),
|
Expires: time.Unix(0, 0),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: cookieSecure,
|
Secure: cookieSecure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"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": {
|
"dependencies": {
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
@ -15,10 +17,15 @@
|
|||||||
"tus-js-client": "^4.3.1"
|
"tus-js-client": "^4.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/react": "^18.3.24",
|
"@types/react": "^18.3.24",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"jsdom": "^27.0.0",
|
||||||
"typescript": "^5.9.2",
|
"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 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 }
|
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 ----------
|
// ---------- helpers ----------
|
||||||
function sanitizeDesc(s: string) {
|
export function sanitizeDesc(s: string) {
|
||||||
s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_')
|
s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||||
if (!s) s = 'upload'
|
if (!s) s = 'upload'
|
||||||
return s.slice(0, 64)
|
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_.-]
|
// 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-]
|
||||||
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
|
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
|
||||||
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
|
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
|
||||||
return s.slice(0, 64)
|
return s.slice(0, 64)
|
||||||
}
|
}
|
||||||
function extOf(n: string) {
|
export function extOf(n: string) {
|
||||||
const i = n.lastIndexOf('.')
|
const i = n.lastIndexOf('.')
|
||||||
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
|
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
|
||||||
}
|
}
|
||||||
function stemOf(n: string) {
|
export function stemOf(n: string) {
|
||||||
const i = n.lastIndexOf('.')
|
const i = n.lastIndexOf('.')
|
||||||
const stem = i > -1 ? n.slice(0, i) : n
|
const stem = i > -1 ? n.slice(0, i) : n
|
||||||
// keep safe charset and avoid extra dots within segments
|
// keep safe charset and avoid extra dots within segments
|
||||||
return sanitizeDesc(stem.replace(/\./g, '_')) || 'file'
|
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 d = date || new Date().toISOString().slice(0, 10)
|
||||||
const [Y, M, D] = d.split('-')
|
const [Y, M, D] = d.split('-')
|
||||||
const sDesc = sanitizeDesc(desc)
|
const sDesc = sanitizeDesc(desc)
|
||||||
@ -39,11 +37,11 @@ function composeName(date: string, desc: string, orig: string) {
|
|||||||
// New pattern: YYYY.MM.DD.Description.OriginalStem.ext
|
// New pattern: YYYY.MM.DD.Description.OriginalStem.ext
|
||||||
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
|
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
|
||||||
}
|
}
|
||||||
function clampOneLevel(p: string) {
|
export function clampOneLevel(p: string) {
|
||||||
if (!p) return ''
|
if (!p) return ''
|
||||||
return p.replace(/^\/+|\/+$/g, '').split('/')[0] || ''
|
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) => ({
|
return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({
|
||||||
name: r?.name ?? r?.Name ?? '',
|
name: r?.name ?? r?.Name ?? '',
|
||||||
path: r?.path ?? r?.Path ?? '',
|
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 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 imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff'])
|
||||||
const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
|
const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
|
||||||
const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name))
|
export 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 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))
|
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
|
if (typeof window === 'undefined') return false
|
||||||
const ua = navigator.userAgent || ''
|
const ua = navigator.userAgent || ''
|
||||||
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
|
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
|
// frontend/vite.config.ts
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@ -8,4 +8,11 @@ export default defineConfig({
|
|||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
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