diff --git a/cmd/cvert-ops/rotate.go b/cmd/cvert-ops/rotate.go index 6eefecbf..e1153858 100644 --- a/cmd/cvert-ops/rotate.go +++ b/cmd/cvert-ops/rotate.go @@ -100,21 +100,22 @@ func rotateEncryptionKeys(ctx context.Context, pool *pgxpool.Pool, currentKey, p return 0, fmt.Errorf("set bypass_rls: %w", err) } - rows, err := tx.Query(ctx, "SELECT id, client_secret_enc FROM sso_connections") + rows, err := tx.Query(ctx, "SELECT id, org_id, client_secret_enc FROM sso_connections") if err != nil { return 0, fmt.Errorf("query sso_connections: %w", err) } defer rows.Close() type pending struct { - id string - enc []byte + id string + orgID [16]byte + enc []byte } var updates []pending for rows.Next() { var p pending - if err := rows.Scan(&p.id, &p.enc); err != nil { + if err := rows.Scan(&p.id, &p.orgID, &p.enc); err != nil { return 0, fmt.Errorf("scan row: %w", err) } updates = append(updates, p) @@ -125,12 +126,12 @@ func rotateEncryptionKeys(ctx context.Context, pool *pgxpool.Pool, currentKey, p count := 0 for _, u := range updates { - plaintext, err := crypto.DecryptWithFallback(currentKey, previousKey, u.enc) + plaintext, err := crypto.DecryptWithFallback(currentKey, previousKey, u.enc, u.orgID[:]) if err != nil { return 0, fmt.Errorf("decrypt row %s: %w", u.id, err) } - newEnc, err := crypto.Encrypt(currentKey, plaintext) + newEnc, err := crypto.Encrypt(currentKey, plaintext, u.orgID[:]) if err != nil { return 0, fmt.Errorf("re-encrypt row %s: %w", u.id, err) } diff --git a/cmd/cvert-ops/rotate_test.go b/cmd/cvert-ops/rotate_test.go index fc9acd0d..b851a37c 100644 --- a/cmd/cvert-ops/rotate_test.go +++ b/cmd/cvert-ops/rotate_test.go @@ -29,9 +29,9 @@ func TestRotateEncryptionKey_ReEncryptsAllValues(t *testing.T) { } orgID := org.ID - // Encrypt a secret with the old key. + // Encrypt a secret with the old key, bound to the org. secret := []byte("my-client-secret") - enc, err := crypto.Encrypt(oldKey, secret) + enc, err := crypto.Encrypt(oldKey, secret, orgID[:]) if err != nil { t.Fatalf("encrypt with old key: %v", err) } @@ -64,7 +64,7 @@ func TestRotateEncryptionKey_ReEncryptsAllValues(t *testing.T) { t.Fatalf("read re-encrypted value: %v", err) } - plaintext, err := crypto.Decrypt(newKey, reEncrypted) + plaintext, err := crypto.Decrypt(newKey, reEncrypted, orgID[:]) if err != nil { t.Fatalf("decrypt with new key failed: %v", err) } @@ -73,7 +73,7 @@ func TestRotateEncryptionKey_ReEncryptsAllValues(t *testing.T) { } // Verify old key alone no longer works. - _, err = crypto.Decrypt(oldKey, reEncrypted) + _, err = crypto.Decrypt(oldKey, reEncrypted, orgID[:]) if err == nil { t.Error("decrypt with old key should fail on re-encrypted data, but succeeded") } diff --git a/dev/specs/sso-secret-storage.md b/dev/specs/sso-secret-storage.md new file mode 100644 index 00000000..06692346 --- /dev/null +++ b/dev/specs/sso-secret-storage.md @@ -0,0 +1,93 @@ +# SSO Secret Storage Architecture + +This document describes how CVErt Ops stores and manages user-provided secrets (specifically, OAuth/OIDC client secrets for enterprise SSO connections) in the production/SaaS configuration. + +## What's Encrypted + +The **only** user-input secret encrypted at rest in the database is `sso_connections.client_secret_enc` — the OIDC client secret that tenants provide when configuring enterprise SSO. It is stored as `BYTEA` in Postgres (migration `000028_sso_connections.up.sql`). + +## Encryption Scheme + +**AES-256-GCM** with random 12-byte nonces, implemented in `internal/crypto/aes.go`. + +- **Ciphertext format:** `nonce (12 bytes) || ciphertext + GCM authentication tag` +- **Nonce source:** `crypto/rand.Reader` (OS CSPRNG) +- **Library:** Go stdlib `crypto/aes` and `crypto/cipher` — no external crypto dependencies + +AES-256-GCM provides both confidentiality and integrity (authenticated encryption). An attacker who obtains a database dump cannot read or tamper with the client secrets without also possessing the encryption key. + +## Key Sourcing + +The encryption key is a raw 32-byte value provided as 64 hex characters via: + +1. **Startup:** The `SSO_ENCRYPTION_KEY` environment variable, parsed by `internal/config/reloadable.go` +2. **Hot-reload:** A secrets file (one `KEY=VALUE` per line) can be reloaded at runtime via `SIGHUP` signal or the admin API reload endpoint. The key is swapped atomically using `atomic.Pointer` in `config.Holder`, so in-flight requests are never disrupted + +The API handler reads the active key via `srv.ssoEncryptionKey()` in `internal/api/sso.go`, which prefers the hot-reloadable config, falling back to the startup config value. + +## Key Rotation + +Key rotation uses a **dual-key** strategy with zero downtime: + +1. **Operator** generates a new 32-byte key (`openssl rand -hex 32`) +2. **Operator** moves the current `SSO_ENCRYPTION_KEY` value to `SSO_ENCRYPTION_KEY_PREVIOUS` and sets the new key as `SSO_ENCRYPTION_KEY` in the secrets file +3. **Operator** reloads config (SIGHUP or admin API) +4. **During the transition window**, all decryption uses `crypto.DecryptWithFallback()` — tries the current key first, then falls back to the previous key on GCM authentication failure. Structural errors (truncated ciphertext, invalid key length) fail fast without attempting fallback +5. **Operator** runs `cvert-ops rotate-encryption-key`, which re-encrypts every `sso_connections.client_secret_enc` row in a single Postgres transaction: decrypt with fallback, re-encrypt with current key +6. **After re-encryption succeeds**, the operator removes `SSO_ENCRYPTION_KEY_PREVIOUS` and reloads config + +The re-encryption command is transactional — if it fails partway through, the transaction rolls back and all rows remain encrypted with the original key. Safe to retry. + +The full step-by-step procedure is documented in `docs/deployment/runbooks/secret-rotation.md`. + +## Security Boundaries and Assumptions + +| Boundary | Status | +|----------|--------| +| **Encryption at rest** | AES-256-GCM. Protects against database dump or backup theft | +| **Tenant isolation** | Row-Level Security (RLS) on `sso_connections` + `org_id` scoping. One tenant cannot read another's encrypted secret | +| **Key storage** | The encryption key lives in an environment variable or secrets file on the host. There is no KMS or HSM wrapping — compromise of the application server's environment means compromise of the key | +| **Memory exposure** | The key is held in process memory as a `[32]byte`. Standard Go runtime — no `mlock` or secure memory wipe. Acceptable for non-HSM deployments | +| **Rotation atomicity** | The `rotate-encryption-key` command runs in a single DB transaction. Failure leaves all rows encrypted with the old key (safe to retry) | +| **No envelope encryption** | There is no KMS-wrapped DEK/KEK split. `SSO_ENCRYPTION_KEY` is the data encryption key directly. Key rotation therefore requires re-encrypting every row (currently only `sso_connections`, so the blast radius is small) | + +### Deployment expectation + +The security model assumes that the deployment environment adequately protects the `SSO_ENCRYPTION_KEY` value. In practice this means: + +- **Container deployments:** Use the platform's native secret injection (Kubernetes Secrets, Docker Swarm secrets, ECS task definition secrets, etc.) +- **Cloud VMs:** Use a cloud secret manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) to inject the value into the environment at startup +- **Self-hosted:** Ensure the secrets file has restrictive file permissions and is excluded from backups and version control + +If CVErt Ops later needs to support a managed SaaS model where the operator controls infrastructure, the natural upgrade path would be envelope encryption with a cloud KMS wrapping the SSO encryption key. + +## What's NOT Encrypted at Rest + +These values are **not** stored in the database — they live only in environment variables or the secrets file: + +- OAuth provider secrets (`GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_SECRET`) — app-level config, not tenant-provided +- JWT signing secrets (`JWT_SECRET`, `JWT_SECRET_PREVIOUS`) +- SMTP credentials (`SMTP_PASSWORD`) + +These values are stored in the database but use **hashing, not encryption** (correct approach — they never need to be recovered in plaintext): + +- User passwords — argon2id +- API key hashes + +## Key Files + +| File | Role | +|------|------| +| `internal/crypto/aes.go` | AES-256-GCM Encrypt / Decrypt / DecryptWithFallback | +| `internal/config/reloadable.go` | Hot-reloadable config with atomic key swap | +| `internal/api/sso.go` | SSO handler — encrypts on write, decrypts on read | +| `cmd/cvert-ops/rotate.go` | CLI re-encryption command | +| `migrations/000028_sso_connections.up.sql` | Schema with `client_secret_enc BYTEA` column + RLS | +| `internal/store/queries/sso.sql` | sqlc queries (encrypted column passed as opaque bytes) | +| `docs/deployment/runbooks/secret-rotation.md` | Operator-facing rotation procedures | + +## Dependencies + +- **Go stdlib crypto** (`crypto/aes`, `crypto/cipher`, `crypto/rand`) — no third-party crypto libraries +- **pgx** for the rotation transaction +- **Operator-managed key** — no external secrets manager SDK dependency diff --git a/go.mod b/go.mod index d1af5cbc..003b55fa 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.1 - github.com/lib/pq v1.12.0 + github.com/lib/pq v1.12.3 github.com/pquerna/otp v1.5.0 github.com/prometheus/client_golang v1.23.2 github.com/sony/gobreaker/v2 v2.4.0 @@ -32,7 +32,7 @@ require ( golang.org/x/crypto v0.49.0 golang.org/x/oauth2 v0.36.0 golang.org/x/time v0.15.0 - google.golang.org/genai v1.52.0 + google.golang.org/genai v1.52.1 ) require ( diff --git a/go.sum b/go.sum index 6da185c1..16586b1b 100644 --- a/go.sum +++ b/go.sum @@ -129,8 +129,8 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= -github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= -github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -276,8 +276,8 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genai v1.52.0 h1:ekVIxWHtLUNbt+v0WWi4j3JT4yrHDEbysMcHQcaCQoI= -google.golang.org/genai v1.52.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.52.1 h1:dYoljKtLDXMiBdVaClSJ/ZPwZ7j1N0lGjMhwOKOQUlk= +google.golang.org/genai v1.52.1/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= diff --git a/internal/api/auth_mfa.go b/internal/api/auth_mfa.go index 401590b1..0ff86a11 100644 --- a/internal/api/auth_mfa.go +++ b/internal/api/auth_mfa.go @@ -374,7 +374,7 @@ func (srv *Server) verifyTOTP(ctx context.Context, userID uuid.UUID, code string return false, fmt.Errorf("encryption key: %w", err) } prevKey := srv.ssoEncryptionKeyPrevious() - secretBytes, err := crypto.DecryptWithFallback(encKey, prevKey, cred.SecretEnc) + secretBytes, err := crypto.DecryptWithFallback(encKey, prevKey, cred.SecretEnc, userID[:]) if err != nil { return false, fmt.Errorf("decrypt TOTP secret: %w", err) } @@ -580,7 +580,7 @@ func (srv *Server) mfaTOTPSetupHandler(ctx context.Context, input *mfaTOTPSetupI slog.ErrorContext(ctx, "totp-setup: encryption key", "error", err) return nil, huma.Error500InternalServerError("encryption key not configured") } - secretEnc, err := crypto.Encrypt(encKey, []byte(key.Secret())) + secretEnc, err := crypto.Encrypt(encKey, []byte(key.Secret()), userID[:]) if err != nil { slog.ErrorContext(ctx, "totp-setup: encrypt secret", "error", err) return nil, huma.Error500InternalServerError("internal error") @@ -645,7 +645,7 @@ func (srv *Server) mfaTOTPConfirmHandler(ctx context.Context, input *mfaTOTPConf return nil, huma.Error500InternalServerError("internal error") } prevKey := srv.ssoEncryptionKeyPrevious() - secretBytes, err := crypto.DecryptWithFallback(encKey, prevKey, enrollClaims.SecretEnc) + secretBytes, err := crypto.DecryptWithFallback(encKey, prevKey, enrollClaims.SecretEnc, userID[:]) if err != nil { slog.ErrorContext(ctx, "totp-confirm: decrypt secret", "error", err) return nil, huma.Error500InternalServerError("internal error") @@ -677,7 +677,7 @@ func (srv *Server) mfaTOTPConfirmHandler(ctx context.Context, input *mfaTOTPConf // Re-encrypt secret for DB storage (enrollment cookie used same key, but // re-encrypt to get a fresh nonce for defense in depth). - secretEncDB, err := crypto.Encrypt(encKey, secretBytes) + secretEncDB, err := crypto.Encrypt(encKey, secretBytes, userID[:]) if err != nil { slog.ErrorContext(ctx, "totp-confirm: re-encrypt secret", "error", err) return nil, huma.Error500InternalServerError("internal error") diff --git a/internal/api/auth_mfa_test.go b/internal/api/auth_mfa_test.go index 0fa9b817..f4531871 100644 --- a/internal/api/auth_mfa_test.go +++ b/internal/api/auth_mfa_test.go @@ -55,7 +55,7 @@ func enrollTOTP(t *testing.T, ctx context.Context, srv *Server, userID uuid.UUID if err != nil { t.Fatalf("enrollTOTP: encryption key: %v", err) } - secretEnc, err := crypto.Encrypt(encKey, []byte(secret)) + secretEnc, err := crypto.Encrypt(encKey, []byte(secret), userID[:]) if err != nil { t.Fatalf("enrollTOTP: encrypt: %v", err) } diff --git a/internal/api/oauth_oidc.go b/internal/api/oauth_oidc.go index 1b32e807..245764a0 100644 --- a/internal/api/oauth_oidc.go +++ b/internal/api/oauth_oidc.go @@ -53,7 +53,7 @@ func (srv *Server) oidcBuildOAuthConfig(ctx context.Context, conn *store.SSOConn if err != nil { return nil, nil, fmt.Errorf("encryption key: %w", err) } - secret, err := crypto.DecryptWithFallback(key, srv.ssoEncryptionKeyPrevious(), conn.ClientSecretEnc) + secret, err := crypto.DecryptWithFallback(key, srv.ssoEncryptionKeyPrevious(), conn.ClientSecretEnc, conn.OrgID[:]) if err != nil { return nil, nil, fmt.Errorf("decrypt secret: %w", err) } diff --git a/internal/api/sso.go b/internal/api/sso.go index 7a5a657a..bc9af9ef 100644 --- a/internal/api/sso.go +++ b/internal/api/sso.go @@ -171,7 +171,7 @@ func (srv *Server) createSSOHandler(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusInternalServerError, "server configuration error") return } - encSecret, err := crypto.Encrypt(key, []byte(req.ClientSecret)) + encSecret, err := crypto.Encrypt(key, []byte(req.ClientSecret), orgID[:]) if err != nil { slog.ErrorContext(r.Context(), "sso create: encrypt secret", "error", err) writeProblem(w, http.StatusInternalServerError, "encryption error") @@ -347,7 +347,7 @@ func (srv *Server) patchSSOHandler(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusInternalServerError, "server configuration error") return } - secretEnc, err = crypto.Encrypt(key, []byte(*req.ClientSecret)) + secretEnc, err = crypto.Encrypt(key, []byte(*req.ClientSecret), orgID[:]) if err != nil { slog.ErrorContext(r.Context(), "sso patch: encrypt secret", "error", err) writeProblem(w, http.StatusInternalServerError, "encryption error") diff --git a/internal/crypto/aes.go b/internal/crypto/aes.go index 2d88fe65..97d801a8 100644 --- a/internal/crypto/aes.go +++ b/internal/crypto/aes.go @@ -15,9 +15,10 @@ import ( // authentication fails and previousKey is non-zero, it retries with // previousKey. This supports seamless encryption key rotation. // Structural errors (truncated ciphertext, invalid key) fail immediately -// without attempting fallback. -func DecryptWithFallback(currentKey, previousKey [32]byte, data []byte) ([]byte, error) { - plaintext, err := Decrypt(currentKey, data) +// without attempting fallback. The aad (additional authenticated data) is +// passed through to GCM and must match the value used during encryption. +func DecryptWithFallback(currentKey, previousKey [32]byte, data []byte, aad []byte) ([]byte, error) { + plaintext, err := Decrypt(currentKey, data, aad) if err == nil { return plaintext, nil } @@ -25,7 +26,7 @@ func DecryptWithFallback(currentKey, previousKey [32]byte, data []byte) ([]byte, // Only fall back on GCM authentication failure (wrong key). // Structural errors (truncated ciphertext, invalid key) fail fast. if previousKey != [32]byte{} && isGCMAuthError(err) { - plaintext, err2 := Decrypt(previousKey, data) + plaintext, err2 := Decrypt(previousKey, data, aad) if err2 == nil { return plaintext, nil } @@ -42,8 +43,10 @@ func isGCMAuthError(err error) bool { } // Encrypt encrypts plaintext using AES-256-GCM with a random nonce. -// Returns nonce || ciphertext. -func Encrypt(key [32]byte, plaintext []byte) ([]byte, error) { +// Returns nonce || ciphertext. The aad (additional authenticated data) is +// mixed into the GCM authentication tag, binding the ciphertext to a context +// (e.g., an org_id or user_id). Pass nil for context-free encryption. +func Encrypt(key [32]byte, plaintext []byte, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key[:]) if err != nil { return nil, fmt.Errorf("aes new cipher: %w", err) @@ -60,12 +63,13 @@ func Encrypt(key [32]byte, plaintext []byte) ([]byte, error) { } // Seal appends ciphertext to nonce, so result is nonce || ciphertext. - return gcm.Seal(nonce, nonce, plaintext, nil), nil + return gcm.Seal(nonce, nonce, plaintext, aad), nil } // Decrypt decrypts AES-256-GCM ciphertext produced by Encrypt. -// Expects nonce (12 bytes) || ciphertext. -func Decrypt(key [32]byte, data []byte) ([]byte, error) { +// Expects nonce (12 bytes) || ciphertext. The aad must match the value +// used during encryption; a mismatch causes an authentication failure. +func Decrypt(key [32]byte, data []byte, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key[:]) if err != nil { return nil, fmt.Errorf("aes new cipher: %w", err) @@ -82,7 +86,7 @@ func Decrypt(key [32]byte, data []byte) ([]byte, error) { } nonce, ciphertext := data[:nonceSize], data[nonceSize:] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + plaintext, err := gcm.Open(nil, nonce, ciphertext, aad) if err != nil { return nil, fmt.Errorf("gcm decrypt: %w", err) } diff --git a/internal/crypto/aes_test.go b/internal/crypto/aes_test.go index 1c5acf1a..2537ce87 100644 --- a/internal/crypto/aes_test.go +++ b/internal/crypto/aes_test.go @@ -23,12 +23,12 @@ func TestAESGCM_RoundTrip(t *testing.T) { key := testKey(t) plaintext := []byte("secret webhook signing key 🔑") - ciphertext, err := Encrypt(key, plaintext) + ciphertext, err := Encrypt(key, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } - got, err := Decrypt(key, ciphertext) + got, err := Decrypt(key, ciphertext, nil) if err != nil { t.Fatalf("Decrypt: %v", err) } @@ -37,16 +37,73 @@ func TestAESGCM_RoundTrip(t *testing.T) { } } +func TestAESGCM_RoundTrip_WithAAD(t *testing.T) { + t.Parallel() + key := testKey(t) + plaintext := []byte("org-scoped secret") + aad := []byte("org-id-abc-123") + + ciphertext, err := Encrypt(key, plaintext, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + got, err := Decrypt(key, ciphertext, aad) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("round-trip mismatch: got %q, want %q", got, plaintext) + } +} + +func TestAESGCM_AADMismatch_Rejected(t *testing.T) { + t.Parallel() + key := testKey(t) + plaintext := []byte("bound to org A") + aadA := []byte("org-A") + aadB := []byte("org-B") + + ciphertext, err := Encrypt(key, plaintext, aadA) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Decrypting with different AAD must fail (ciphertext relocation attack). + _, err = Decrypt(key, ciphertext, aadB) + if err == nil { + t.Error("Decrypt succeeded with wrong AAD, want authentication failure") + } +} + +func TestAESGCM_AADVsNilAAD_Rejected(t *testing.T) { + t.Parallel() + key := testKey(t) + plaintext := []byte("has AAD binding") + aad := []byte("some-context") + + ciphertext, err := Encrypt(key, plaintext, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Encrypted with AAD, decrypted without — must fail. + _, err = Decrypt(key, ciphertext, nil) + if err == nil { + t.Error("Decrypt with nil AAD succeeded on AAD-encrypted data, want failure") + } +} + func TestAESGCM_UniqueNonce(t *testing.T) { t.Parallel() key := testKey(t) plaintext := []byte("same input") - ct1, err := Encrypt(key, plaintext) + ct1, err := Encrypt(key, plaintext, nil) if err != nil { t.Fatalf("Encrypt 1: %v", err) } - ct2, err := Encrypt(key, plaintext) + ct2, err := Encrypt(key, plaintext, nil) if err != nil { t.Fatalf("Encrypt 2: %v", err) } @@ -60,7 +117,7 @@ func TestAESGCM_TamperedCiphertext(t *testing.T) { t.Parallel() key := testKey(t) - ciphertext, err := Encrypt(key, []byte("tamper me")) + ciphertext, err := Encrypt(key, []byte("tamper me"), nil) if err != nil { t.Fatalf("Encrypt: %v", err) } @@ -70,7 +127,7 @@ func TestAESGCM_TamperedCiphertext(t *testing.T) { copy(tampered, ciphertext) tampered[len(tampered)-1] ^= 0xff - _, err = Decrypt(key, tampered) + _, err = Decrypt(key, tampered, nil) if err == nil { t.Error("Decrypt succeeded on tampered ciphertext, want error") } @@ -81,12 +138,12 @@ func TestAESGCM_WrongKey(t *testing.T) { key1 := testKey(t) key2 := testKey(t) - ciphertext, err := Encrypt(key1, []byte("wrong key test")) + ciphertext, err := Encrypt(key1, []byte("wrong key test"), nil) if err != nil { t.Fatalf("Encrypt: %v", err) } - _, err = Decrypt(key2, ciphertext) + _, err = Decrypt(key2, ciphertext, nil) if err == nil { t.Error("Decrypt succeeded with wrong key, want error") } @@ -96,12 +153,12 @@ func TestAESGCM_EmptyPlaintext(t *testing.T) { t.Parallel() key := testKey(t) - ciphertext, err := Encrypt(key, []byte{}) + ciphertext, err := Encrypt(key, []byte{}, nil) if err != nil { t.Fatalf("Encrypt empty: %v", err) } - got, err := Decrypt(key, ciphertext) + got, err := Decrypt(key, ciphertext, nil) if err != nil { t.Fatalf("Decrypt empty: %v", err) } @@ -116,7 +173,7 @@ func TestAESGCM_ShortCiphertext(t *testing.T) { // ciphertext too short to contain a nonce is rejected at runtime. key := testKey(t) - _, err := Decrypt(key, []byte("short")) + _, err := Decrypt(key, []byte("short"), nil) if err == nil { t.Error("Decrypt succeeded on too-short ciphertext, want error") } @@ -130,12 +187,12 @@ func TestDecryptWithFallback_CurrentKeyWorks(t *testing.T) { previousKey := testKey(t) plaintext := []byte("current key decryption") - ciphertext, err := Encrypt(currentKey, plaintext) + ciphertext, err := Encrypt(currentKey, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } - got, err := DecryptWithFallback(currentKey, previousKey, ciphertext) + got, err := DecryptWithFallback(currentKey, previousKey, ciphertext, nil) if err != nil { t.Fatalf("DecryptWithFallback: %v", err) } @@ -150,13 +207,13 @@ func TestDecryptWithFallback_PreviousKeyWorks(t *testing.T) { newKey := testKey(t) plaintext := []byte("encrypted with old key") - ciphertext, err := Encrypt(oldKey, plaintext) + ciphertext, err := Encrypt(oldKey, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } // newKey as current fails GCM auth; oldKey as previous succeeds. - got, err := DecryptWithFallback(newKey, oldKey, ciphertext) + got, err := DecryptWithFallback(newKey, oldKey, ciphertext, nil) if err != nil { t.Fatalf("DecryptWithFallback: %v", err) } @@ -172,12 +229,12 @@ func TestDecryptWithFallback_BothKeysWrong(t *testing.T) { keyC := testKey(t) plaintext := []byte("neither key works") - ciphertext, err := Encrypt(keyA, plaintext) + ciphertext, err := Encrypt(keyA, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } - _, err = DecryptWithFallback(keyB, keyC, ciphertext) + _, err = DecryptWithFallback(keyB, keyC, ciphertext, nil) if err == nil { t.Error("DecryptWithFallback succeeded with both wrong keys, want error") } @@ -189,13 +246,13 @@ func TestDecryptWithFallback_NoPreviousKey(t *testing.T) { var zeroKey [32]byte plaintext := []byte("no previous key") - ciphertext, err := Encrypt(currentKey, plaintext) + ciphertext, err := Encrypt(currentKey, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } // Zero previous key → only current key tried. - got, err := DecryptWithFallback(currentKey, zeroKey, ciphertext) + got, err := DecryptWithFallback(currentKey, zeroKey, ciphertext, nil) if err != nil { t.Fatalf("DecryptWithFallback: %v", err) } @@ -212,7 +269,7 @@ func TestDecryptWithFallback_TruncatedCiphertext_NoFallback(t *testing.T) { previousKey := [32]byte{2} shortData := []byte("short") - _, err := DecryptWithFallback(currentKey, previousKey, shortData) + _, err := DecryptWithFallback(currentKey, previousKey, shortData, nil) if err == nil { t.Fatal("DecryptWithFallback succeeded on truncated ciphertext, want error") } @@ -231,14 +288,53 @@ func TestDecryptWithFallback_NoPreviousKeyCurrentFails(t *testing.T) { keyB := testKey(t) var zeroKey [32]byte - ciphertext, err := Encrypt(keyA, []byte("no previous key fails")) + ciphertext, err := Encrypt(keyA, []byte("no previous key fails"), nil) if err != nil { t.Fatalf("Encrypt: %v", err) } // Wrong current key, zero previous → returns error without panic. - _, err = DecryptWithFallback(keyB, zeroKey, ciphertext) + _, err = DecryptWithFallback(keyB, zeroKey, ciphertext, nil) if err == nil { t.Error("DecryptWithFallback succeeded with wrong current and zero previous, want error") } } + +func TestDecryptWithFallback_WithAAD(t *testing.T) { + t.Parallel() + currentKey := testKey(t) + previousKey := testKey(t) + plaintext := []byte("aad-bound secret") + aad := []byte("org-id-bytes") + + ciphertext, err := Encrypt(currentKey, plaintext, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + got, err := DecryptWithFallback(currentKey, previousKey, ciphertext, aad) + if err != nil { + t.Fatalf("DecryptWithFallback: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("plaintext mismatch: got %q, want %q", got, plaintext) + } +} + +func TestDecryptWithFallback_AADMismatch_Rejected(t *testing.T) { + t.Parallel() + currentKey := testKey(t) + var zeroKey [32]byte + plaintext := []byte("bound to org A") + + ciphertext, err := Encrypt(currentKey, plaintext, []byte("org-A")) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Correct key but wrong AAD must fail. + _, err = DecryptWithFallback(currentKey, zeroKey, ciphertext, []byte("org-B")) + if err == nil { + t.Error("DecryptWithFallback succeeded with wrong AAD, want error") + } +} diff --git a/internal/doctor/checks.go b/internal/doctor/checks.go index 4f2bbf57..5593812e 100644 --- a/internal/doctor/checks.go +++ b/internal/doctor/checks.go @@ -182,7 +182,7 @@ func (c *EncryptionSentinelCheck) Run(ctx context.Context) (string, string, erro return StatusFail, fmt.Sprintf("query system_settings: %v", err), nil } - _, err = crypto.DecryptWithFallback(c.Key, c.PreviousKey, value) + _, err = crypto.DecryptWithFallback(c.Key, c.PreviousKey, value, []byte("encryption_sentinel")) if err != nil { return StatusFail, fmt.Sprintf("sentinel decryption failed: %v — encryption key may have changed", err), nil } @@ -344,8 +344,8 @@ func (c *SecurityHeadersCheck) Run(ctx context.Context) (string, string, error) required := map[string]string{ "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", } var missing []string @@ -444,7 +444,7 @@ type StandardChecksConfig struct { SMTPHost string SMTPPort int SMTPUsername string - CORSAllowedOrigins string + CORSAllowedOrigins string CookieAuth bool ServerAddr string // empty in CLI mode, "http://localhost:{port}" in API mode } diff --git a/web/package-lock.json b/web/package-lock.json index 08e4a40d..370f0c6f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,16 +16,16 @@ "lucide-vue-next": "^0.577.0", "openapi-fetch": "^0.17.0", "pinia": "^3.0.4", - "reka-ui": "^2.9.1", + "reka-ui": "^2.9.3", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "vue": "^3.5.30", + "vue": "^3.5.32", "vue-router": "^5.0.4", "vue-sonner": "^2.0.9" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", - "@types/node": "^24.11.0", + "@types/node": "^25.5.2", "@vitejs/plugin-vue": "^6.0.4", "@vitest/eslint-plugin": "^1.6.13", "@vue/eslint-config-typescript": "^14.7.0", @@ -33,13 +33,13 @@ "@vue/tsconfig": "^0.9.0", "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-oxlint": "~1.56.0", + "eslint-plugin-oxlint": "~1.58.0", "eslint-plugin-vue": "~10.8.0", "jiti": "^2.6.1", "jsdom": "^29.0.0", "npm-run-all2": "^8.0.4", "openapi-typescript": "^7.13.0", - "oxlint": "~1.56.0", + "oxlint": "~1.58.0", "prettier": "3.8.1", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", @@ -756,9 +756,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.56.0.tgz", - "integrity": "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.58.0.tgz", + "integrity": "sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg==", "cpu": [ "arm" ], @@ -773,9 +773,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.56.0.tgz", - "integrity": "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.58.0.tgz", + "integrity": "sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg==", "cpu": [ "arm64" ], @@ -790,9 +790,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.56.0.tgz", - "integrity": "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.58.0.tgz", + "integrity": "sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA==", "cpu": [ "arm64" ], @@ -807,9 +807,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.56.0.tgz", - "integrity": "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.58.0.tgz", + "integrity": "sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg==", "cpu": [ "x64" ], @@ -824,9 +824,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.56.0.tgz", - "integrity": "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.58.0.tgz", + "integrity": "sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw==", "cpu": [ "x64" ], @@ -841,9 +841,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.56.0.tgz", - "integrity": "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.58.0.tgz", + "integrity": "sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q==", "cpu": [ "arm" ], @@ -858,9 +858,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.56.0.tgz", - "integrity": "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.58.0.tgz", + "integrity": "sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g==", "cpu": [ "arm" ], @@ -875,9 +875,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.56.0.tgz", - "integrity": "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.58.0.tgz", + "integrity": "sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ==", "cpu": [ "arm64" ], @@ -892,9 +892,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.56.0.tgz", - "integrity": "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.58.0.tgz", + "integrity": "sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw==", "cpu": [ "arm64" ], @@ -909,9 +909,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.56.0.tgz", - "integrity": "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.58.0.tgz", + "integrity": "sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA==", "cpu": [ "ppc64" ], @@ -926,9 +926,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.56.0.tgz", - "integrity": "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.58.0.tgz", + "integrity": "sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg==", "cpu": [ "riscv64" ], @@ -943,9 +943,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.56.0.tgz", - "integrity": "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.58.0.tgz", + "integrity": "sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q==", "cpu": [ "riscv64" ], @@ -960,9 +960,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.56.0.tgz", - "integrity": "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.58.0.tgz", + "integrity": "sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog==", "cpu": [ "s390x" ], @@ -977,9 +977,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.56.0.tgz", - "integrity": "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.58.0.tgz", + "integrity": "sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w==", "cpu": [ "x64" ], @@ -994,9 +994,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.56.0.tgz", - "integrity": "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.58.0.tgz", + "integrity": "sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w==", "cpu": [ "x64" ], @@ -1011,9 +1011,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.56.0.tgz", - "integrity": "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.58.0.tgz", + "integrity": "sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg==", "cpu": [ "arm64" ], @@ -1028,9 +1028,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.56.0.tgz", - "integrity": "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.58.0.tgz", + "integrity": "sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ==", "cpu": [ "arm64" ], @@ -1045,9 +1045,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.56.0.tgz", - "integrity": "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.58.0.tgz", + "integrity": "sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ==", "cpu": [ "ia32" ], @@ -1062,9 +1062,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.56.0.tgz", - "integrity": "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.58.0.tgz", + "integrity": "sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg==", "cpu": [ "x64" ], @@ -1865,13 +1865,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", - "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/web-bluetooth": { @@ -1881,20 +1881,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", - "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/type-utils": "8.57.1", - "@typescript-eslint/utils": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1904,9 +1904,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.1", + "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1920,16 +1920,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", - "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "engines": { @@ -1941,18 +1941,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", - "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.1", - "@typescript-eslint/types": "^8.57.1", + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "engines": { @@ -1963,18 +1963,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", - "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1" + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1985,9 +1985,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", - "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", "dev": true, "license": "MIT", "engines": { @@ -1998,21 +1998,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", - "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2023,13 +2023,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", - "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", "dev": true, "license": "MIT", "engines": { @@ -2041,21 +2041,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", - "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.1", - "@typescript-eslint/tsconfig-utils": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/visitor-keys": "8.57.1", + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2065,20 +2065,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", - "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.1", - "@typescript-eslint/types": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1" + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2089,17 +2089,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", - "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2351,39 +2351,39 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", - "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", - "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", - "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.30", - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30", + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", @@ -2391,13 +2391,13 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", - "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/devtools-api": { @@ -2489,53 +2489,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", - "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.30" + "@vue/shared": "3.5.32" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", - "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", - "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/runtime-core": "3.5.30", - "@vue/shared": "3.5.30", + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", - "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" }, "peerDependencies": { - "vue": "3.5.30" + "vue": "3.5.32" } }, "node_modules/@vue/shared": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", - "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -2550,13 +2550,13 @@ } }, "node_modules/@vue/tsconfig": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.0.tgz", - "integrity": "sha512-RP+v9Cpbsk1ZVXltCHHkYBr7+624x6gcijJXVjIcsYk7JXqvIpRtMwU2ARLvWDhmy9ffdFYxhsfJnPztADBohQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", "dev": true, "license": "MIT", "peerDependencies": { - "typescript": "5.x", + "typescript": ">= 5.8", "vue": "^3.4.0" }, "peerDependenciesMeta": { @@ -3263,13 +3263,16 @@ } }, "node_modules/eslint-plugin-oxlint": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.56.0.tgz", - "integrity": "sha512-s47/OjE4cfQ+CD4eA38g+5axvwuyswY5H6acCdVGIvowYuLVJ6zrR7N260XfVVLRuyjjPO9L77qNYwSbmRNyuw==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.58.0.tgz", + "integrity": "sha512-L3aZSg0x2fL0dXyOgoK8A1QUbnfGzXt6bX4AFD7Scauw6zVUBOZrES5eRTzLLGgeVg0el5lvqHGl1WFAGo14DA==", "dev": true, "license": "MIT", "dependencies": { "jsonc-parser": "^3.3.1" + }, + "peerDependencies": { + "oxlint": "~1.58.0" } }, "node_modules/eslint-plugin-vue": { @@ -4702,9 +4705,9 @@ } }, "node_modules/oxlint": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.56.0.tgz", - "integrity": "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g==", + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.58.0.tgz", + "integrity": "sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg==", "dev": true, "license": "MIT", "bin": { @@ -4717,28 +4720,28 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.56.0", - "@oxlint/binding-android-arm64": "1.56.0", - "@oxlint/binding-darwin-arm64": "1.56.0", - "@oxlint/binding-darwin-x64": "1.56.0", - "@oxlint/binding-freebsd-x64": "1.56.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", - "@oxlint/binding-linux-arm-musleabihf": "1.56.0", - "@oxlint/binding-linux-arm64-gnu": "1.56.0", - "@oxlint/binding-linux-arm64-musl": "1.56.0", - "@oxlint/binding-linux-ppc64-gnu": "1.56.0", - "@oxlint/binding-linux-riscv64-gnu": "1.56.0", - "@oxlint/binding-linux-riscv64-musl": "1.56.0", - "@oxlint/binding-linux-s390x-gnu": "1.56.0", - "@oxlint/binding-linux-x64-gnu": "1.56.0", - "@oxlint/binding-linux-x64-musl": "1.56.0", - "@oxlint/binding-openharmony-arm64": "1.56.0", - "@oxlint/binding-win32-arm64-msvc": "1.56.0", - "@oxlint/binding-win32-ia32-msvc": "1.56.0", - "@oxlint/binding-win32-x64-msvc": "1.56.0" + "@oxlint/binding-android-arm-eabi": "1.58.0", + "@oxlint/binding-android-arm64": "1.58.0", + "@oxlint/binding-darwin-arm64": "1.58.0", + "@oxlint/binding-darwin-x64": "1.58.0", + "@oxlint/binding-freebsd-x64": "1.58.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.58.0", + "@oxlint/binding-linux-arm-musleabihf": "1.58.0", + "@oxlint/binding-linux-arm64-gnu": "1.58.0", + "@oxlint/binding-linux-arm64-musl": "1.58.0", + "@oxlint/binding-linux-ppc64-gnu": "1.58.0", + "@oxlint/binding-linux-riscv64-gnu": "1.58.0", + "@oxlint/binding-linux-riscv64-musl": "1.58.0", + "@oxlint/binding-linux-s390x-gnu": "1.58.0", + "@oxlint/binding-linux-x64-gnu": "1.58.0", + "@oxlint/binding-linux-x64-musl": "1.58.0", + "@oxlint/binding-openharmony-arm64": "1.58.0", + "@oxlint/binding-win32-arm64-msvc": "1.58.0", + "@oxlint/binding-win32-ia32-msvc": "1.58.0", + "@oxlint/binding-win32-x64-msvc": "1.58.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.15.0" + "oxlint-tsgolint": ">=0.18.0" }, "peerDependenciesMeta": { "oxlint-tsgolint": { @@ -5109,9 +5112,9 @@ } }, "node_modules/reka-ui": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.2.tgz", - "integrity": "sha512-/t4e6y1hcG+uDuRfpg6tbMz3uUEvRzNco6NeYTufoJeUghy5Iosxos5YL/p+ieAsid84sdMX9OrgDqpEuCJhBw==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.5.tgz", + "integrity": "sha512-6cZGIMgEeslpFLJ7IihaCSMPp1cJgl2eDkZ2vBMdl+HPUVBaV/iDPMWu3abT2KUkj1lir+oyHq5KelOTT9OheQ==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", @@ -5122,7 +5125,7 @@ "@vueuse/core": "^14.1.0", "@vueuse/shared": "^14.1.0", "aria-hidden": "^1.2.4", - "defu": "^6.1.4", + "defu": "^6.1.5", "ohash": "^2.0.11" }, "funding": { @@ -5640,9 +5643,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -5709,16 +5712,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", - "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.1", - "@typescript-eslint/parser": "8.57.1", - "@typescript-eslint/typescript-estree": "8.57.1", - "@typescript-eslint/utils": "8.57.1" + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5729,7 +5732,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/ufo": { @@ -5749,9 +5752,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "devOptional": true, "license": "MIT" }, @@ -6025,16 +6028,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", - "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-sfc": "3.5.30", - "@vue/runtime-dom": "3.5.30", - "@vue/server-renderer": "3.5.30", - "@vue/shared": "3.5.30" + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" diff --git a/web/package.json b/web/package.json index 33c826a4..436ed838 100644 --- a/web/package.json +++ b/web/package.json @@ -25,16 +25,16 @@ "lucide-vue-next": "^0.577.0", "openapi-fetch": "^0.17.0", "pinia": "^3.0.4", - "reka-ui": "^2.9.1", + "reka-ui": "^2.9.3", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "vue": "^3.5.30", + "vue": "^3.5.32", "vue-router": "^5.0.4", "vue-sonner": "^2.0.9" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", - "@types/node": "^24.11.0", + "@types/node": "^25.5.2", "@vitejs/plugin-vue": "^6.0.4", "@vitest/eslint-plugin": "^1.6.13", "@vue/eslint-config-typescript": "^14.7.0", @@ -42,13 +42,13 @@ "@vue/tsconfig": "^0.9.0", "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-oxlint": "~1.56.0", + "eslint-plugin-oxlint": "~1.58.0", "eslint-plugin-vue": "~10.8.0", "jiti": "^2.6.1", "jsdom": "^29.0.0", "npm-run-all2": "^8.0.4", "openapi-typescript": "^7.13.0", - "oxlint": "~1.56.0", + "oxlint": "~1.58.0", "prettier": "3.8.1", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", diff --git a/web/src/components/__tests__/AppSidebar.test.ts b/web/src/components/__tests__/AppSidebar.test.ts index 04696d2e..fdef46e2 100644 --- a/web/src/components/__tests__/AppSidebar.test.ts +++ b/web/src/components/__tests__/AppSidebar.test.ts @@ -8,8 +8,8 @@ import { useAuthStore } from '@/stores/auth' // Mock vue-router vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ path: '/cves' })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ path: '/cves' })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -20,8 +20,8 @@ vi.mock('vue-router', () => ({ // Mock API client (needed by auth store) vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), - POST: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) @@ -126,7 +126,7 @@ describe('OrgSwitcher', () => { user_id: 'u1', email: 'sam@example.com', display_name: 'Sam Carter', - is_site_admin: false, + is_site_admin: false, orgs: [ { org_id: 'org-1', name: 'Acme Corp', role: 'owner' }, { org_id: 'org-2', name: 'Globex Inc', role: 'member' }, diff --git a/web/src/components/cve/__tests__/CveResultsTable.test.ts b/web/src/components/cve/__tests__/CveResultsTable.test.ts index bc46c910..956aca4f 100644 --- a/web/src/components/cve/__tests__/CveResultsTable.test.ts +++ b/web/src/components/cve/__tests__/CveResultsTable.test.ts @@ -8,8 +8,8 @@ import type { components } from '@/lib/api/schema' type CVEItem = components['schemas']['CVEItem'] vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ query: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -20,7 +20,8 @@ vi.mock('vue-router', () => ({ function makeCVE(overrides: Partial = {}): CVEItem { return { cve_id: 'CVE-2024-12345', - description_primary: 'A critical vulnerability in Apache Log4j allows remote code execution via crafted log messages.', + description_primary: + 'A critical vulnerability in Apache Log4j allows remote code execution via crafted log messages.', cvss_v3_score: 9.8, epss_score: 0.975, severity: 'critical', @@ -133,7 +134,7 @@ describe('CveResultsTable', () => { const wrapper = await mountTable({ items }) const cells = wrapper.findAll('td') - const epssCell = cells.find(c => c.text() === '\u2014') + const epssCell = cells.find((c) => c.text() === '\u2014') expect(epssCell).toBeDefined() }) }) diff --git a/web/src/components/cve/__tests__/CveSearchFilters.test.ts b/web/src/components/cve/__tests__/CveSearchFilters.test.ts index 9d2c4056..c41aa524 100644 --- a/web/src/components/cve/__tests__/CveSearchFilters.test.ts +++ b/web/src/components/cve/__tests__/CveSearchFilters.test.ts @@ -5,8 +5,8 @@ import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ query: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], diff --git a/web/src/components/cve/__tests__/CveSourceComparison.test.ts b/web/src/components/cve/__tests__/CveSourceComparison.test.ts index 7898396b..ad3678ec 100644 --- a/web/src/components/cve/__tests__/CveSourceComparison.test.ts +++ b/web/src/components/cve/__tests__/CveSourceComparison.test.ts @@ -8,8 +8,8 @@ import type { components } from '@/lib/api/schema' type CVESourceResponse = components['schemas']['CVESourceResponse'] vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ query: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -34,9 +34,7 @@ function makeSource(overrides: Partial = {}): CVESourceRespon } async function mountComponent(props: Record = {}) { - const { default: CveSourceComparison } = await import( - '@/components/cve/CveSourceComparison.vue' - ) + const { default: CveSourceComparison } = await import('@/components/cve/CveSourceComparison.vue') return mount(CveSourceComparison, { props: props as any }) } diff --git a/web/src/components/settings/__tests__/GroupDialog.test.ts b/web/src/components/settings/__tests__/GroupDialog.test.ts index 765a02c0..3c97ab64 100644 --- a/web/src/components/settings/__tests__/GroupDialog.test.ts +++ b/web/src/components/settings/__tests__/GroupDialog.test.ts @@ -8,8 +8,8 @@ import { useAuthStore } from '@/stores/auth' import type { GroupEntry } from '@/components/settings/GroupDialog.vue' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ params: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -17,15 +17,15 @@ vi.mock('vue-router', () => ({ }, })) -const mockPOST = vi.fn() -const mockPATCH = vi.fn() +const mockPOST = vi.fn<(...args: unknown[]) => unknown>() +const mockPATCH = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), POST: (...args: unknown[]) => mockPOST(...args), PATCH: (...args: unknown[]) => mockPATCH(...args), - DELETE: vi.fn(), + DELETE: vi.fn<(...args: unknown[]) => unknown>(), }, })) diff --git a/web/src/components/settings/__tests__/GroupMembersDialog.test.ts b/web/src/components/settings/__tests__/GroupMembersDialog.test.ts index 311f3902..223b6184 100644 --- a/web/src/components/settings/__tests__/GroupMembersDialog.test.ts +++ b/web/src/components/settings/__tests__/GroupMembersDialog.test.ts @@ -7,8 +7,8 @@ import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ params: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -16,15 +16,15 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() -const mockPOST = vi.fn() -const mockDELETE = vi.fn() +const mockGET = vi.fn<(...args: unknown[]) => unknown>() +const mockPOST = vi.fn<(...args: unknown[]) => unknown>() +const mockDELETE = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { GET: (...args: unknown[]) => mockGET(...args), POST: (...args: unknown[]) => mockPOST(...args), - PATCH: vi.fn(), + PATCH: vi.fn<(...args: unknown[]) => unknown>(), DELETE: (...args: unknown[]) => mockDELETE(...args), }, })) @@ -93,9 +93,8 @@ function bodyText(): string { let wrapper: VueWrapper async function mountDialog(props: { open?: boolean; groupId?: string; groupName?: string } = {}) { - const { default: GroupMembersDialog } = await import( - '@/components/settings/GroupMembersDialog.vue' - ) + const { default: GroupMembersDialog } = + await import('@/components/settings/GroupMembersDialog.vue') wrapper = mount(GroupMembersDialog, { props: { open: true, @@ -110,7 +109,9 @@ async function mountDialog(props: { open?: boolean; groupId?: string; groupName? // Clean up portaled DOM elements function cleanupPortals() { - document.querySelectorAll('[data-reka-portal], [data-radix-popper-content-wrapper]').forEach((el) => el.remove()) + document + .querySelectorAll('[data-reka-portal], [data-radix-popper-content-wrapper]') + .forEach((el) => el.remove()) } describe('GroupMembersDialog', () => { @@ -250,9 +251,7 @@ describe('GroupMembersDialog', () => { describe('add member', () => { it('shows available org members not already in group', async () => { - mockGroupMembersSuccess([ - makeGroupMember({ user_id: 'u1', email: 'alice@example.com' }), - ]) + mockGroupMembersSuccess([makeGroupMember({ user_id: 'u1', email: 'alice@example.com' })]) mockOrgMembersSuccess([ makeOrgMember({ user_id: 'u1', email: 'alice@example.com' }), makeOrgMember({ user_id: 'u2', email: 'bob@example.com', display_name: 'Bob' }), diff --git a/web/src/components/settings/__tests__/InviteMemberDialog.test.ts b/web/src/components/settings/__tests__/InviteMemberDialog.test.ts index aa08f61d..52686e87 100644 --- a/web/src/components/settings/__tests__/InviteMemberDialog.test.ts +++ b/web/src/components/settings/__tests__/InviteMemberDialog.test.ts @@ -7,8 +7,8 @@ import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ params: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -16,14 +16,14 @@ vi.mock('vue-router', () => ({ }, })) -const mockPOST = vi.fn() +const mockPOST = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), POST: (...args: unknown[]) => mockPOST(...args), - PATCH: vi.fn(), - DELETE: vi.fn(), + PATCH: vi.fn<(...args: unknown[]) => unknown>(), + DELETE: vi.fn<(...args: unknown[]) => unknown>(), }, })) @@ -74,9 +74,8 @@ async function clickTestId(testId: string) { let wrapper: VueWrapper async function mountDialog(props: { open?: boolean; currentUserRole?: string } = {}) { - const { default: InviteMemberDialog } = await import( - '@/components/settings/InviteMemberDialog.vue' - ) + const { default: InviteMemberDialog } = + await import('@/components/settings/InviteMemberDialog.vue') wrapper = mount(InviteMemberDialog, { props: { open: true, currentUserRole: 'admin', ...props }, attachTo: document.body, diff --git a/web/src/components/watchlist/__tests__/AddItemDialog.test.ts b/web/src/components/watchlist/__tests__/AddItemDialog.test.ts index 0bbe1718..d1492428 100644 --- a/web/src/components/watchlist/__tests__/AddItemDialog.test.ts +++ b/web/src/components/watchlist/__tests__/AddItemDialog.test.ts @@ -7,8 +7,8 @@ import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ params: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -16,14 +16,14 @@ vi.mock('vue-router', () => ({ }, })) -const mockPOST = vi.fn() +const mockPOST = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), POST: (...args: unknown[]) => mockPOST(...args), - PATCH: vi.fn(), - DELETE: vi.fn(), + PATCH: vi.fn<(...args: unknown[]) => unknown>(), + DELETE: vi.fn<(...args: unknown[]) => unknown>(), }, })) @@ -112,9 +112,7 @@ async function clickTestId(testId: string) { let wrapper: VueWrapper async function mountDialog(open = true) { - const { default: AddItemDialog } = await import( - '@/components/watchlist/AddItemDialog.vue' - ) + const { default: AddItemDialog } = await import('@/components/watchlist/AddItemDialog.vue') wrapper = mount(AddItemDialog, { props: { open, watchlistId: TEST_WATCHLIST_ID }, attachTo: document.body, diff --git a/web/src/components/watchlist/__tests__/CreateWatchlistDialog.test.ts b/web/src/components/watchlist/__tests__/CreateWatchlistDialog.test.ts index e06ad824..c1c1820c 100644 --- a/web/src/components/watchlist/__tests__/CreateWatchlistDialog.test.ts +++ b/web/src/components/watchlist/__tests__/CreateWatchlistDialog.test.ts @@ -7,8 +7,8 @@ import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ params: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -16,14 +16,14 @@ vi.mock('vue-router', () => ({ }, })) -const mockPOST = vi.fn() +const mockPOST = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), POST: (...args: unknown[]) => mockPOST(...args), - PATCH: vi.fn(), - DELETE: vi.fn(), + PATCH: vi.fn<(...args: unknown[]) => unknown>(), + DELETE: vi.fn<(...args: unknown[]) => unknown>(), }, })) @@ -91,9 +91,8 @@ async function clickTestId(testId: string) { let wrapper: VueWrapper async function mountDialog(open = true) { - const { default: CreateWatchlistDialog } = await import( - '@/components/watchlist/CreateWatchlistDialog.vue' - ) + const { default: CreateWatchlistDialog } = + await import('@/components/watchlist/CreateWatchlistDialog.vue') wrapper = mount(CreateWatchlistDialog, { props: { open }, attachTo: document.body, @@ -166,7 +165,7 @@ describe('CreateWatchlistDialog', () => { ) // Verify the body includes name and description - const callArgs = mockPOST.mock.calls[0]! + const callArgs = mockPOST.mock.calls[0] as [string, { body: Record }] expect(callArgs[1].body.name).toBe('Test WL') expect(callArgs[1].body.description).toBe('Desc') }) @@ -183,7 +182,7 @@ describe('CreateWatchlistDialog', () => { await clickTestId('create-watchlist-btn') await flushPromises() - const callArgs = mockPOST.mock.calls[0]! + const callArgs = mockPOST.mock.calls[0] as [string, { body: Record }] expect(callArgs[1].body.name).toBe('Name Only') expect(callArgs[1].body.description).toBeNull() }) diff --git a/web/src/lib/api/__tests__/client.test.ts b/web/src/lib/api/__tests__/client.test.ts index 289ad5b0..744ab851 100644 --- a/web/src/lib/api/__tests__/client.test.ts +++ b/web/src/lib/api/__tests__/client.test.ts @@ -119,7 +119,7 @@ describe('refresh middleware', () => { it('does not attempt refresh for auth endpoints', async () => { const { refreshMiddleware } = await import('../client') - const fetchMock = vi.fn() + const fetchMock = vi.fn() globalThis.fetch = fetchMock const loginRequest = new Request('http://localhost/api/v1/auth/login') @@ -136,7 +136,7 @@ describe('refresh middleware', () => { it('does not attempt refresh for auth/me endpoint', async () => { const { refreshMiddleware } = await import('../client') - const fetchMock = vi.fn() + const fetchMock = vi.fn() globalThis.fetch = fetchMock const meRequest = new Request('http://localhost/api/v1/auth/me') @@ -153,7 +153,7 @@ describe('refresh middleware', () => { it('does not attempt refresh for the refresh endpoint itself', async () => { const { refreshMiddleware } = await import('../client') - const fetchMock = vi.fn() + const fetchMock = vi.fn() globalThis.fetch = fetchMock const refreshRequest = new Request('http://localhost/api/v1/auth/refresh') @@ -172,7 +172,7 @@ describe('refresh middleware', () => { const { refreshMiddleware } = await import('../client') const retryResponse = new Response('ok', { status: 200 }) - const fetchMock = vi.fn() + const fetchMock = vi.fn() // First call: refresh succeeds. fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })) // Second call: retry the original request. @@ -198,7 +198,7 @@ describe('refresh middleware', () => { it('returns original 401 response when refresh fails', async () => { const { refreshMiddleware } = await import('../client') - const fetchMock = vi.fn() + const fetchMock = vi.fn() // Refresh returns 401 (failure). fetchMock.mockResolvedValueOnce(new Response('', { status: 401 })) globalThis.fetch = fetchMock diff --git a/web/src/router/__tests__/guards.test.ts b/web/src/router/__tests__/guards.test.ts index 08b57dc3..9909ef56 100644 --- a/web/src/router/__tests__/guards.test.ts +++ b/web/src/router/__tests__/guards.test.ts @@ -11,8 +11,8 @@ import { routes, authGuard, titleGuard } from '../index' // Mock the API client so fetchMe doesn't make real HTTP calls. vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), - POST: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) diff --git a/web/src/stores/__tests__/auth.test.ts b/web/src/stores/__tests__/auth.test.ts index aa0d23eb..4231a55f 100644 --- a/web/src/stores/__tests__/auth.test.ts +++ b/web/src/stores/__tests__/auth.test.ts @@ -8,8 +8,8 @@ import { useAuthStore } from '../auth' // Mock the API client vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), - POST: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) @@ -120,7 +120,11 @@ describe('auth store', () => { is_site_admin: false, orgs: [{ org_id: 'org-1', name: 'Org One', role: 'owner' }], } - vi.mocked(client.GET).mockResolvedValue({ data: meData, error: undefined, response: {} as Response }) + vi.mocked(client.GET).mockResolvedValue({ + data: meData, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() const result = await auth.fetchMe() @@ -131,7 +135,11 @@ describe('auth store', () => { }) it('returns false on API error', async () => { - vi.mocked(client.GET).mockResolvedValue({ data: undefined, error: { type: 'about:blank', detail: 'unauthorized' }, response: {} as Response }) + vi.mocked(client.GET).mockResolvedValue({ + data: undefined, + error: { type: 'about:blank', detail: 'unauthorized' }, + response: {} as Response, + }) const auth = useAuthStore() const result = await auth.fetchMe() @@ -148,7 +156,11 @@ describe('auth store', () => { is_site_admin: false, orgs: [{ org_id: 'only-org', name: 'Only Org', role: 'admin' }], } - vi.mocked(client.GET).mockResolvedValue({ data: meData, error: undefined, response: {} as Response }) + vi.mocked(client.GET).mockResolvedValue({ + data: meData, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() await auth.fetchMe() @@ -168,7 +180,11 @@ describe('auth store', () => { { org_id: 'org-2', name: 'Org Two', role: 'member' }, ], } - vi.mocked(client.GET).mockResolvedValue({ data: meData, error: undefined, response: {} as Response }) + vi.mocked(client.GET).mockResolvedValue({ + data: meData, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() await auth.fetchMe() @@ -185,7 +201,11 @@ describe('auth store', () => { is_site_admin: false, orgs: [{ org_id: 'current-org', name: 'Current', role: 'admin' }], } - vi.mocked(client.GET).mockResolvedValue({ data: meData, error: undefined, response: {} as Response }) + vi.mocked(client.GET).mockResolvedValue({ + data: meData, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() await auth.fetchMe() @@ -207,7 +227,11 @@ describe('auth store', () => { { org_id: 'org-2', name: 'Org Two', role: 'member' }, ], } - vi.mocked(client.GET).mockResolvedValue({ data: meData, error: undefined, response: {} as Response }) + vi.mocked(client.GET).mockResolvedValue({ + data: meData, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() await auth.fetchMe() @@ -230,7 +254,11 @@ describe('auth store', () => { is_site_admin: false, orgs: [], } - vi.mocked(client.GET).mockResolvedValue({ data: meData, error: undefined, response: {} as Response }) + vi.mocked(client.GET).mockResolvedValue({ + data: meData, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() await auth.fetchMe() @@ -239,7 +267,11 @@ describe('auth store', () => { }) it('is set to true after failed fetchMe', async () => { - vi.mocked(client.GET).mockResolvedValue({ data: undefined, error: { type: 'about:blank', detail: 'unauthorized' }, response: {} as Response }) + vi.mocked(client.GET).mockResolvedValue({ + data: undefined, + error: { type: 'about:blank', detail: 'unauthorized' }, + response: {} as Response, + }) const auth = useAuthStore() await auth.fetchMe() @@ -248,7 +280,11 @@ describe('auth store', () => { }) it('is reset to false on clearAuth', async () => { - vi.mocked(client.POST).mockResolvedValue({ data: undefined, error: undefined, response: {} as Response }) + vi.mocked(client.POST).mockResolvedValue({ + data: undefined, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() auth.sessionChecked = true @@ -268,8 +304,16 @@ describe('auth store', () => { is_site_admin: false, orgs: [{ org_id: 'org-1', name: 'Org One', role: 'admin' }], } - vi.mocked(client.POST).mockResolvedValue({ data: undefined, error: undefined, response: {} as Response }) - vi.mocked(client.GET).mockResolvedValue({ data: meData, error: undefined, response: {} as Response }) + vi.mocked(client.POST).mockResolvedValue({ + data: undefined, + error: undefined, + response: {} as Response, + }) + vi.mocked(client.GET).mockResolvedValue({ + data: meData, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() const result = await auth.login('test@example.com', 'password123') @@ -280,7 +324,11 @@ describe('auth store', () => { }) it('returns error on failed login', async () => { - vi.mocked(client.POST).mockResolvedValue({ data: undefined, error: { type: 'about:blank', detail: 'bad creds' }, response: {} as Response }) + vi.mocked(client.POST).mockResolvedValue({ + data: undefined, + error: { type: 'about:blank', detail: 'bad creds' }, + response: {} as Response, + }) const auth = useAuthStore() const result = await auth.login('bad@example.com', 'wrong') @@ -293,7 +341,11 @@ describe('auth store', () => { describe('logout', () => { it('calls logout endpoint and clears auth state', async () => { - vi.mocked(client.POST).mockResolvedValue({ data: undefined, error: undefined, response: {} as Response }) + vi.mocked(client.POST).mockResolvedValue({ + data: undefined, + error: undefined, + response: {} as Response, + }) const auth = useAuthStore() auth.user = { diff --git a/web/src/views/__tests__/CreateOrgView.test.ts b/web/src/views/__tests__/CreateOrgView.test.ts index 5d37fade..5b6b7717 100644 --- a/web/src/views/__tests__/CreateOrgView.test.ts +++ b/web/src/views/__tests__/CreateOrgView.test.ts @@ -6,11 +6,11 @@ import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: {} })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => { query: Record }>(() => ({ query: {} })), + useRouter: vi.fn<() => { push: typeof mockPush }>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -18,11 +18,11 @@ vi.mock('vue-router', () => ({ }, })) -const mockPOST = vi.fn() +const mockPOST = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), POST: (...args: unknown[]) => mockPOST(...args), }, })) @@ -65,7 +65,10 @@ describe('CreateOrgView', () => { let resolvePost: (value: unknown) => void mockPOST.mockImplementation( - () => new Promise((resolve) => { resolvePost = resolve }), + () => + new Promise((resolve) => { + resolvePost = resolve + }), ) const wrapper = await mountCreateOrg() diff --git a/web/src/views/__tests__/CveDetailView.test.ts b/web/src/views/__tests__/CveDetailView.test.ts index 4db8745e..a4bfe559 100644 --- a/web/src/views/__tests__/CveDetailView.test.ts +++ b/web/src/views/__tests__/CveDetailView.test.ts @@ -12,13 +12,15 @@ type CVESourceResponse = components['schemas']['CVESourceResponse'] let mockRouteParams: Record = {} vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ + useRoute: vi.fn<() => { params: Record; query: Record }>(() => ({ params: mockRouteParams, query: {}, })), - useRouter: vi.fn(() => ({ - push: vi.fn(), - back: vi.fn(), + useRouter: vi.fn< + () => { push: (...args: unknown[]) => unknown; back: (...args: unknown[]) => unknown } + >(() => ({ + push: vi.fn<(...args: unknown[]) => unknown>(), + back: vi.fn<(...args: unknown[]) => unknown>(), })), RouterLink: { name: 'RouterLink', @@ -27,12 +29,12 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() +const mockGET = vi.fn<(path: string, ...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { - GET: (...args: unknown[]) => mockGET(...args), - POST: vi.fn(), + GET: (path: string, ...args: unknown[]) => mockGET(path, ...args), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) @@ -200,7 +202,7 @@ describe('CveDetailView', () => { await flushPromises() const scoreCards = wrapper.findAll('[data-testid="score-card"]') - const cvssCard = scoreCards.find(c => c.text().includes('CVSS')) + const cvssCard = scoreCards.find((c) => c.text().includes('CVSS')) expect(cvssCard?.text()).toContain('N/A') }) @@ -210,7 +212,7 @@ describe('CveDetailView', () => { await flushPromises() const scoreCards = wrapper.findAll('[data-testid="score-card"]') - const epssCard = scoreCards.find(c => c.text().includes('EPSS')) + const epssCard = scoreCards.find((c) => c.text().includes('EPSS')) expect(epssCard?.text()).toContain('N/A') }) @@ -280,7 +282,7 @@ describe('CveDetailView', () => { await flushPromises() const links = wrapper.findAll('a[target="_blank"]') - const urls = links.map(l => l.attributes('href')) + const urls = links.map((l) => l.attributes('href')) expect(urls).toContain('https://nvd.nist.gov/vuln/detail/CVE-2024-12345') expect(urls).toContain('https://github.com/advisories/GHSA-xxxx-xxxx-xxxx') }) @@ -370,7 +372,9 @@ describe('CveDetailView', () => { // Set up a slow response (will become stale) let resolveStale: (v: unknown) => void - const stalePromise = new Promise((resolve) => { resolveStale = resolve }) + const stalePromise = new Promise((resolve) => { + resolveStale = resolve + }) mockGET.mockReturnValueOnce(stalePromise) // Trigger first refetch — increments fetchId diff --git a/web/src/views/__tests__/CveSearchView.test.ts b/web/src/views/__tests__/CveSearchView.test.ts index bd6e3a17..ecf22a24 100644 --- a/web/src/views/__tests__/CveSearchView.test.ts +++ b/web/src/views/__tests__/CveSearchView.test.ts @@ -8,13 +8,18 @@ import type { components } from '@/lib/api/schema' type CVEItem = components['schemas']['CVEItem'] -const mockPush = vi.fn() -const mockReplace = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() +const mockReplace = vi.fn<(...args: unknown[]) => unknown>() let mockRouteQuery: Record = {} vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: mockRouteQuery })), - useRouter: vi.fn(() => ({ push: mockPush, replace: mockReplace })), + useRoute: vi.fn<() => { query: Record }>(() => ({ + query: mockRouteQuery, + })), + useRouter: vi.fn<() => { push: typeof mockPush; replace: typeof mockReplace }>(() => ({ + push: mockPush, + replace: mockReplace, + })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -22,12 +27,12 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() +const mockGET = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { GET: (...args: unknown[]) => mockGET(...args), - POST: vi.fn(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) @@ -114,11 +119,14 @@ describe('CveSearchView', () => { await mountView() await flushPromises() - expect(mockGET).toHaveBeenCalledWith('/cves', expect.objectContaining({ - params: expect.objectContaining({ - query: expect.any(Object), + expect(mockGET).toHaveBeenCalledWith( + '/cves', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.any(Object), + }), }), - })) + ) }) it('displays fetched CVE results', async () => { @@ -139,13 +147,16 @@ describe('CveSearchView', () => { await mountView() await flushPromises() - expect(mockGET).toHaveBeenCalledWith('/cves', expect.objectContaining({ - params: { - query: expect.objectContaining({ - q: 'apache', - }), - }, - })) + expect(mockGET).toHaveBeenCalledWith( + '/cves', + expect.objectContaining({ + params: { + query: expect.objectContaining({ + q: 'apache', + }), + }, + }), + ) }) it('passes severity filter to API', async () => { @@ -154,13 +165,16 @@ describe('CveSearchView', () => { await mountView() await flushPromises() - expect(mockGET).toHaveBeenCalledWith('/cves', expect.objectContaining({ - params: { - query: expect.objectContaining({ - severity: ['critical'], - }), - }, - })) + expect(mockGET).toHaveBeenCalledWith( + '/cves', + expect.objectContaining({ + params: { + query: expect.objectContaining({ + severity: ['critical'], + }), + }, + }), + ) }) }) @@ -190,11 +204,13 @@ describe('CveSearchView', () => { await wrapper.find('form').trigger('submit') await flushPromises() - expect(mockReplace).toHaveBeenCalledWith(expect.objectContaining({ - query: expect.objectContaining({ - q: 'openssl', + expect(mockReplace).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + q: 'openssl', + }), }), - })) + ) }) }) @@ -240,13 +256,16 @@ describe('CveSearchView', () => { await wrapper.find('[data-testid="next-page"]').trigger('click') await flushPromises() - expect(mockGET).toHaveBeenCalledWith('/cves', expect.objectContaining({ - params: { - query: expect.objectContaining({ - cursor: 'cursor-page2', - }), - }, - })) + expect(mockGET).toHaveBeenCalledWith( + '/cves', + expect.objectContaining({ + params: { + query: expect.objectContaining({ + cursor: 'cursor-page2', + }), + }, + }), + ) expect(wrapper.text()).toContain('CVE-2024-0002') }) diff --git a/web/src/views/__tests__/FeedStatusView.test.ts b/web/src/views/__tests__/FeedStatusView.test.ts index ae6d8cd0..f959a355 100644 --- a/web/src/views/__tests__/FeedStatusView.test.ts +++ b/web/src/views/__tests__/FeedStatusView.test.ts @@ -5,8 +5,10 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ path: '/admin/feeds' })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => { path: string }>(() => ({ path: '/admin/feeds' })), + useRouter: vi.fn<() => { push: (...args: unknown[]) => unknown }>(() => ({ + push: vi.fn<(...args: unknown[]) => unknown>(), + })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -14,8 +16,8 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() -const mockPOST = vi.fn() +const mockGET = vi.fn<(...args: unknown[]) => unknown>() +const mockPOST = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { diff --git a/web/src/views/__tests__/ForgotPasswordView.test.ts b/web/src/views/__tests__/ForgotPasswordView.test.ts index efc2c661..efe29204 100644 --- a/web/src/views/__tests__/ForgotPasswordView.test.ts +++ b/web/src/views/__tests__/ForgotPasswordView.test.ts @@ -7,8 +7,10 @@ import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: {} })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => { query: Record }>(() => ({ query: {} })), + useRouter: vi.fn<() => { push: (...args: unknown[]) => unknown }>(() => ({ + push: vi.fn<(...args: unknown[]) => unknown>(), + })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -18,14 +20,14 @@ vi.mock('vue-router', () => ({ vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), - POST: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) import { useAuthStore } from '@/stores/auth' -const mockFetch = vi.fn() +const mockFetch = vi.fn<(...args: unknown[]) => unknown>() vi.stubGlobal('fetch', mockFetch) async function mountForgotPassword() { @@ -96,7 +98,10 @@ describe('ForgotPasswordView', () => { it('shows success message even on failure (anti-enumeration)', async () => { const auth = useAuthStore() - vi.spyOn(auth, 'forgotPassword').mockResolvedValue({ success: false, error: 'something went wrong' }) + vi.spyOn(auth, 'forgotPassword').mockResolvedValue({ + success: false, + error: 'something went wrong', + }) const wrapper = await mountForgotPassword() @@ -125,7 +130,10 @@ describe('ForgotPasswordView', () => { const auth = useAuthStore() let resolveForgot: (value: { success: boolean }) => void vi.spyOn(auth, 'forgotPassword').mockImplementation( - () => new Promise((resolve) => { resolveForgot = resolve }), + () => + new Promise((resolve) => { + resolveForgot = resolve + }), ) const wrapper = await mountForgotPassword() diff --git a/web/src/views/__tests__/GroupsView.test.ts b/web/src/views/__tests__/GroupsView.test.ts index e201066f..2a94212e 100644 --- a/web/src/views/__tests__/GroupsView.test.ts +++ b/web/src/views/__tests__/GroupsView.test.ts @@ -6,11 +6,11 @@ import { mount, flushPromises, VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: {} })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => { params: Record }>(() => ({ params: {} })), + useRouter: vi.fn<() => { push: typeof mockPush }>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -18,14 +18,14 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() -const mockDELETE = vi.fn() +const mockGET = vi.fn<(...args: unknown[]) => unknown>() +const mockDELETE = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { GET: (...args: unknown[]) => mockGET(...args), - POST: vi.fn(), - PATCH: vi.fn(), + POST: vi.fn<(...args: unknown[]) => unknown>(), + PATCH: vi.fn<(...args: unknown[]) => unknown>(), DELETE: (...args: unknown[]) => mockDELETE(...args), }, })) @@ -97,7 +97,9 @@ async function mountView() { // Clean up portaled DOM elements (reka-ui Select, AlertDialog, Dialog) function cleanupPortals() { - document.querySelectorAll('[data-reka-portal], [data-radix-popper-content-wrapper]').forEach((el) => el.remove()) + document + .querySelectorAll('[data-reka-portal], [data-radix-popper-content-wrapper]') + .forEach((el) => el.remove()) } describe('GroupsView', () => { diff --git a/web/src/views/__tests__/InvitationView.test.ts b/web/src/views/__tests__/InvitationView.test.ts index ee585084..39738d9a 100644 --- a/web/src/views/__tests__/InvitationView.test.ts +++ b/web/src/views/__tests__/InvitationView.test.ts @@ -7,11 +7,11 @@ import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' let mockRouteParams: Record = { token: 'test-token-abc' } -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: mockRouteParams })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => { params: Record }>(() => ({ params: mockRouteParams })), + useRouter: vi.fn<() => { push: typeof mockPush }>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -19,8 +19,8 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() -const mockPOST = vi.fn() +const mockGET = vi.fn<(...args: unknown[]) => unknown>() +const mockPOST = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { @@ -178,7 +178,7 @@ describe('InvitationView', () => { user_id: 'u1', email: 'sam@example.com', display_name: 'Sam Carter', - is_site_admin: false, + is_site_admin: false, orgs: [ { org_id: 'org-old', name: 'Old Org', role: 'admin' }, { org_id: 'org-new', name: 'Acme Corp', role: 'member' }, diff --git a/web/src/views/__tests__/LoginView.test.ts b/web/src/views/__tests__/LoginView.test.ts index 22b9057e..8de55d27 100644 --- a/web/src/views/__tests__/LoginView.test.ts +++ b/web/src/views/__tests__/LoginView.test.ts @@ -6,12 +6,12 @@ import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() const mockRouteQuery = { redirect: undefined as string | undefined } vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: mockRouteQuery })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => { query: typeof mockRouteQuery }>(() => ({ query: mockRouteQuery })), + useRouter: vi.fn<() => { push: typeof mockPush }>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -21,8 +21,8 @@ vi.mock('vue-router', () => ({ vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), - POST: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) @@ -271,7 +271,7 @@ describe('LoginView', () => { it('GitHub button redirects to OAuth endpoint', async () => { mockProvidersResponse(true, false) const originalLocation = window.location.href - const hrefSetter = vi.fn() + const hrefSetter = vi.fn<(v: string) => void>() Object.defineProperty(window, 'location', { value: { ...window.location, @@ -299,7 +299,7 @@ describe('LoginView', () => { it('Google button redirects to OAuth endpoint', async () => { mockProvidersResponse(false, true) const originalLocation = window.location.href - const hrefSetter = vi.fn() + const hrefSetter = vi.fn<(v: string) => void>() Object.defineProperty(window, 'location', { value: { ...window.location, diff --git a/web/src/views/__tests__/MembersView.test.ts b/web/src/views/__tests__/MembersView.test.ts index 5b7eaf22..266a53fd 100644 --- a/web/src/views/__tests__/MembersView.test.ts +++ b/web/src/views/__tests__/MembersView.test.ts @@ -6,11 +6,11 @@ import { mount, flushPromises, VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: {} })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => unknown>(() => ({ params: {} })), + useRouter: vi.fn<() => unknown>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -18,14 +18,14 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() -const mockPATCH = vi.fn() -const mockDELETE = vi.fn() +const mockGET = vi.fn<(...args: unknown[]) => unknown>() +const mockPATCH = vi.fn<(...args: unknown[]) => unknown>() +const mockDELETE = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { GET: (...args: unknown[]) => mockGET(...args), - POST: vi.fn(), + POST: vi.fn<(...args: unknown[]) => unknown>(), PATCH: (...args: unknown[]) => mockPATCH(...args), DELETE: (...args: unknown[]) => mockDELETE(...args), }, @@ -121,7 +121,9 @@ async function openRoleSelectAndGetOptions(): Promise { trigger.hasPointerCapture = () => false trigger.releasePointerCapture = () => {} } - trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1 })) + trigger.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1 }), + ) await flushPromises() const options = document.querySelectorAll('[role="option"]') return Array.from(options).map((el) => el.textContent?.trim() ?? '') @@ -139,7 +141,9 @@ async function mountView() { // Clean up portaled DOM elements (reka-ui Select, AlertDialog, Dialog) function cleanupPortals() { - document.querySelectorAll('[data-reka-portal], [data-radix-popper-content-wrapper]').forEach((el) => el.remove()) + document + .querySelectorAll('[data-reka-portal], [data-radix-popper-content-wrapper]') + .forEach((el) => el.remove()) } describe('MembersView', () => { @@ -193,8 +197,18 @@ describe('MembersView', () => { it('renders members table with data', async () => { setupAuthStore('admin') mockMembersSuccess([ - makeMember({ user_id: 'u1', email: 'alice@example.com', display_name: 'Alice', role: 'admin' }), - makeMember({ user_id: 'u2', email: 'bob@example.com', display_name: 'Bob', role: 'member' }), + makeMember({ + user_id: 'u1', + email: 'alice@example.com', + display_name: 'Alice', + role: 'admin', + }), + makeMember({ + user_id: 'u2', + email: 'bob@example.com', + display_name: 'Bob', + role: 'member', + }), ]) mockInvitationsSuccess([]) await mountView() @@ -322,9 +336,7 @@ describe('MembersView', () => { it('hides remove button on owner members', async () => { setupAuthStore('admin') - mockMembersSuccess([ - makeMember({ user_id: 'u1', role: 'owner', email: 'owner@example.com' }), - ]) + mockMembersSuccess([makeMember({ user_id: 'u1', role: 'owner', email: 'owner@example.com' })]) mockInvitationsSuccess([]) await mountView() await flushPromises() @@ -364,8 +376,18 @@ describe('MembersView', () => { it('calls DELETE on confirmation and removes from list', async () => { setupAuthStore('admin') mockMembersSuccess([ - makeMember({ user_id: 'u1', email: 'keep@example.com', display_name: 'Keep', role: 'member' }), - makeMember({ user_id: 'u2', email: 'remove@example.com', display_name: 'Remove', role: 'member' }), + makeMember({ + user_id: 'u1', + email: 'keep@example.com', + display_name: 'Keep', + role: 'member', + }), + makeMember({ + user_id: 'u2', + email: 'remove@example.com', + display_name: 'Remove', + role: 'member', + }), ]) mockInvitationsSuccess([]) await mountView() @@ -428,9 +450,7 @@ describe('MembersView', () => { it('shows role select for admin on non-owner members', async () => { setupAuthStore('admin') - mockMembersSuccess([ - makeMember({ user_id: 'u1', role: 'member' }), - ]) + mockMembersSuccess([makeMember({ user_id: 'u1', role: 'member' })]) mockInvitationsSuccess([]) await mountView() await flushPromises() @@ -441,9 +461,7 @@ describe('MembersView', () => { it('shows plain text role for owner members (not changeable)', async () => { setupAuthStore('admin') - mockMembersSuccess([ - makeMember({ user_id: 'u1', role: 'owner' }), - ]) + mockMembersSuccess([makeMember({ user_id: 'u1', role: 'owner' })]) mockInvitationsSuccess([]) await mountView() await flushPromises() @@ -458,9 +476,7 @@ describe('MembersView', () => { it('calls PATCH when role is changed', async () => { setupAuthStore('owner') - mockMembersSuccess([ - makeMember({ user_id: 'u1', role: 'member' }), - ]) + mockMembersSuccess([makeMember({ user_id: 'u1', role: 'member' })]) mockInvitationsSuccess([]) await mountView() await flushPromises() @@ -484,9 +500,7 @@ describe('MembersView', () => { it('shows plain text role badge for non-admin users', async () => { setupAuthStore('viewer') - mockMembersSuccess([ - makeMember({ user_id: 'u1', role: 'member' }), - ]) + mockMembersSuccess([makeMember({ user_id: 'u1', role: 'member' })]) await mountView() await flushPromises() @@ -552,9 +566,7 @@ describe('MembersView', () => { it('shows error when cancelling invitation fails', async () => { setupAuthStore('admin') mockMembersSuccess([makeMember()]) - mockInvitationsSuccess([ - makeInvitation({ id: 'inv-1', email: 'fail@example.com' }), - ]) + mockInvitationsSuccess([makeInvitation({ id: 'inv-1', email: 'fail@example.com' })]) await mountView() await flushPromises() @@ -577,9 +589,7 @@ describe('MembersView', () => { describe('role change error handling', () => { it('reverts role display and shows error when PATCH fails', async () => { setupAuthStore('owner') - mockMembersSuccess([ - makeMember({ user_id: 'u1', role: 'admin', email: 'admin@example.com' }), - ]) + mockMembersSuccess([makeMember({ user_id: 'u1', role: 'admin', email: 'admin@example.com' })]) mockInvitationsSuccess([]) await mountView() await flushPromises() diff --git a/web/src/views/__tests__/NotFoundView.test.ts b/web/src/views/__tests__/NotFoundView.test.ts index 86f4bdb5..f9822485 100644 --- a/web/src/views/__tests__/NotFoundView.test.ts +++ b/web/src/views/__tests__/NotFoundView.test.ts @@ -5,8 +5,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { mount } from '@vue/test-utils' vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ path: '/nonexistent' })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => unknown>(() => ({ path: '/nonexistent' })), + useRouter: vi.fn<() => unknown>(() => ({ push: vi.fn<(...args: unknown[]) => unknown>() })), RouterLink: { name: 'RouterLink', props: ['to'], diff --git a/web/src/views/__tests__/RegisterView.test.ts b/web/src/views/__tests__/RegisterView.test.ts index 99352f27..7018452f 100644 --- a/web/src/views/__tests__/RegisterView.test.ts +++ b/web/src/views/__tests__/RegisterView.test.ts @@ -6,11 +6,11 @@ import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: {} })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => { query: Record }>(() => ({ query: {} })), + useRouter: vi.fn<() => { push: typeof mockPush }>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -20,8 +20,8 @@ vi.mock('vue-router', () => ({ vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), - POST: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) diff --git a/web/src/views/__tests__/ResetPasswordView.test.ts b/web/src/views/__tests__/ResetPasswordView.test.ts index 226d388d..aea30908 100644 --- a/web/src/views/__tests__/ResetPasswordView.test.ts +++ b/web/src/views/__tests__/ResetPasswordView.test.ts @@ -6,12 +6,12 @@ import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() const mockRouteQuery = { token: undefined as string | undefined } vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: mockRouteQuery })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => { query: typeof mockRouteQuery }>(() => ({ query: mockRouteQuery })), + useRouter: vi.fn<() => { push: typeof mockPush }>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -21,14 +21,14 @@ vi.mock('vue-router', () => ({ vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), - POST: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) import { useAuthStore } from '@/stores/auth' -const mockFetch = vi.fn() +const mockFetch = vi.fn<(...args: unknown[]) => unknown>() vi.stubGlobal('fetch', mockFetch) async function mountResetPassword() { @@ -118,7 +118,10 @@ describe('ResetPasswordView', () => { await wrapper.find('form').trigger('submit') await flushPromises() - expect(auth.resetPassword).toHaveBeenCalledWith('valid-hex-token-abc123', 'new-password-1234567') + expect(auth.resetPassword).toHaveBeenCalledWith( + 'valid-hex-token-abc123', + 'new-password-1234567', + ) }) it('shows success message after successful reset', async () => { @@ -187,7 +190,10 @@ describe('ResetPasswordView', () => { const auth = useAuthStore() let resolveReset: (value: { success: boolean }) => void vi.spyOn(auth, 'resetPassword').mockImplementation( - () => new Promise((resolve) => { resolveReset = resolve }), + () => + new Promise((resolve) => { + resolveReset = resolve + }), ) const wrapper = await mountResetPassword() diff --git a/web/src/views/__tests__/VerifyEmailView.test.ts b/web/src/views/__tests__/VerifyEmailView.test.ts index ac0bbcfc..14ac4b31 100644 --- a/web/src/views/__tests__/VerifyEmailView.test.ts +++ b/web/src/views/__tests__/VerifyEmailView.test.ts @@ -8,8 +8,10 @@ import { createPinia, setActivePinia } from 'pinia' const mockRouteQuery = { token: undefined as string | undefined } vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ query: mockRouteQuery })), - useRouter: vi.fn(() => ({ push: vi.fn() })), + useRoute: vi.fn<() => { query: typeof mockRouteQuery }>(() => ({ query: mockRouteQuery })), + useRouter: vi.fn<() => { push: (...args: unknown[]) => unknown }>(() => ({ + push: vi.fn<(...args: unknown[]) => unknown>(), + })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -19,14 +21,14 @@ vi.mock('vue-router', () => ({ vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn(), - POST: vi.fn(), + GET: vi.fn<(...args: unknown[]) => unknown>(), + POST: vi.fn<(...args: unknown[]) => unknown>(), }, })) import { useAuthStore } from '@/stores/auth' -const mockFetch = vi.fn() +const mockFetch = vi.fn<(...args: unknown[]) => unknown>() vi.stubGlobal('fetch', mockFetch) async function mountVerifyEmail() { @@ -109,7 +111,10 @@ describe('VerifyEmailView', () => { it('shows helpful expired link text on error', async () => { const auth = useAuthStore() - vi.spyOn(auth, 'verifyEmail').mockResolvedValue({ success: false, error: 'Verification failed' }) + vi.spyOn(auth, 'verifyEmail').mockResolvedValue({ + success: false, + error: 'Verification failed', + }) const wrapper = await mountVerifyEmail() await flushPromises() diff --git a/web/src/views/__tests__/WatchlistDetailView.test.ts b/web/src/views/__tests__/WatchlistDetailView.test.ts index f3a37ce8..458cc9b1 100644 --- a/web/src/views/__tests__/WatchlistDetailView.test.ts +++ b/web/src/views/__tests__/WatchlistDetailView.test.ts @@ -7,11 +7,11 @@ import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' let mockRouteParams: Record = { id: 'wl-123' } -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: mockRouteParams })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => { params: Record }>(() => ({ params: mockRouteParams })), + useRouter: vi.fn<() => { push: typeof mockPush }>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -19,14 +19,14 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() -const mockPATCH = vi.fn() -const mockDELETE = vi.fn() +const mockGET = vi.fn<(...args: unknown[]) => unknown>() +const mockPATCH = vi.fn<(...args: unknown[]) => unknown>() +const mockDELETE = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { GET: (...args: unknown[]) => mockGET(...args), - POST: vi.fn(), + POST: vi.fn<(...args: unknown[]) => unknown>(), PATCH: (...args: unknown[]) => mockPATCH(...args), DELETE: (...args: unknown[]) => mockDELETE(...args), }, @@ -134,9 +134,7 @@ async function clickTestId(testId: string) { let wrapper: VueWrapper async function mountView() { - const { default: WatchlistDetailView } = await import( - '@/views/WatchlistDetailView.vue' - ) + const { default: WatchlistDetailView } = await import('@/views/WatchlistDetailView.vue') wrapper = mount(WatchlistDetailView, { attachTo: document.body, }) diff --git a/web/src/views/__tests__/WatchlistListView.test.ts b/web/src/views/__tests__/WatchlistListView.test.ts index a580dc5f..13892b7f 100644 --- a/web/src/views/__tests__/WatchlistListView.test.ts +++ b/web/src/views/__tests__/WatchlistListView.test.ts @@ -6,11 +6,11 @@ import { mount, flushPromises, VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { useAuthStore } from '@/stores/auth' -const mockPush = vi.fn() +const mockPush = vi.fn<(...args: unknown[]) => unknown>() vi.mock('vue-router', () => ({ - useRoute: vi.fn(() => ({ params: {} })), - useRouter: vi.fn(() => ({ push: mockPush })), + useRoute: vi.fn<() => { params: Record }>(() => ({ params: {} })), + useRouter: vi.fn<() => { push: typeof mockPush }>(() => ({ push: mockPush })), RouterLink: { name: 'RouterLink', props: ['to'], @@ -18,14 +18,14 @@ vi.mock('vue-router', () => ({ }, })) -const mockGET = vi.fn() -const mockDELETE = vi.fn() +const mockGET = vi.fn<(...args: unknown[]) => unknown>() +const mockDELETE = vi.fn<(...args: unknown[]) => unknown>() vi.mock('@/lib/api/client', () => ({ default: { GET: (...args: unknown[]) => mockGET(...args), - POST: vi.fn(), - PATCH: vi.fn(), + POST: vi.fn<(...args: unknown[]) => unknown>(), + PATCH: vi.fn<(...args: unknown[]) => unknown>(), DELETE: (...args: unknown[]) => mockDELETE(...args), }, })) diff --git a/web/src/views/admin/__tests__/AdminSystemView.test.ts b/web/src/views/admin/__tests__/AdminSystemView.test.ts index 6a901f66..4412e139 100644 --- a/web/src/views/admin/__tests__/AdminSystemView.test.ts +++ b/web/src/views/admin/__tests__/AdminSystemView.test.ts @@ -24,7 +24,9 @@ const unhealthyDoctor = { // Stub the openapi-fetch client used by the component. vi.mock('@/lib/api/client', () => ({ default: { - GET: vi.fn().mockResolvedValue({ data: null, error: { status: 500 } }), + GET: vi + .fn<(...args: unknown[]) => unknown>() + .mockResolvedValue({ data: null, error: { status: 500 } }), }, })) diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index dccf2bd6..4be693aa 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -9,7 +9,6 @@ "noUncheckedIndexedAccess": true, // Path mapping for cleaner imports. - "baseUrl": ".", "paths": { "@/*": ["./src/*"] }, diff --git a/web/tsconfig.json b/web/tsconfig.json index 1702e9dd..a3e245f9 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -12,7 +12,6 @@ } ], "compilerOptions": { - "baseUrl": ".", "paths": { "@/*": ["./src/*"] }