646 lines
21 KiB
Go
646 lines
21 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTestServer(t *testing.T) (*Server, error) {
|
|
t.Helper()
|
|
return newServerForTest(t, DefaultConfig())
|
|
}
|
|
|
|
func newServerForTest(t *testing.T, cfg *Config) (*Server, error) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
return NewServer(filepath.Join(dir, "server.db"), filepath.Join(dir, "data"), cfg)
|
|
}
|
|
|
|
func serveJSON(t *testing.T, s *Server, method, path, token string, body interface{}) (int, map[string]interface{}) {
|
|
t.Helper()
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(data))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
res := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(res, req)
|
|
result := make(map[string]interface{})
|
|
if len(res.Body.Bytes()) > 0 {
|
|
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("decode response: %v (%s)", err, res.Body.String())
|
|
}
|
|
}
|
|
return res.Code, result
|
|
}
|
|
|
|
func insertScopedSyncDevice(t *testing.T, s *Server, deviceID, userID, vaultID, token string) {
|
|
t.Helper()
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, err := s.db.Exec(`INSERT INTO server_devices
|
|
(id, name, api_key, token_hash, user_id, vault_id, last_seen, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, deviceID, deviceID, "legacy-"+deviceID, sha256Hex(token), userID, vaultID, now, now); err != nil {
|
|
t.Fatalf("insert scoped device: %v", err)
|
|
}
|
|
}
|
|
|
|
func uploadBlob(t *testing.T, s *Server, token string, data []byte) (int, map[string]interface{}) {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("file", "blob.bin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write(data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/blobs/", &body)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
res := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(res, req)
|
|
result := map[string]interface{}{}
|
|
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("decode blob response: %v (%s)", err, res.Body.String())
|
|
}
|
|
return res.Code, result
|
|
}
|
|
|
|
func TestDefaultListenAddressIsLoopback(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
if got, want := cfg.ListenAddress(), "127.0.0.1:47732"; got != want {
|
|
t.Fatalf("default listen address = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHTTPServerUsesExplicitTimeouts(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
|
|
httpServer := s.HTTPServer("127.0.0.1:0")
|
|
if httpServer.ReadHeaderTimeout <= 0 || httpServer.ReadTimeout <= 0 || httpServer.WriteTimeout <= 0 || httpServer.IdleTimeout <= 0 {
|
|
t.Fatalf("HTTP timeouts must all be set: %#v", httpServer)
|
|
}
|
|
if httpServer.MaxHeaderBytes <= 0 {
|
|
t.Fatalf("MaxHeaderBytes must be set: %#v", httpServer)
|
|
}
|
|
}
|
|
|
|
func TestHTTPServerGracefulShutdown(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := s.HTTPServer(listener.Addr().String())
|
|
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
|
done := make(chan error, 1)
|
|
go func() { done <- server.Serve(listener) }()
|
|
|
|
response, err := http.Get("http://" + listener.Addr().String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = response.Body.Close()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
t.Fatalf("shutdown: %v", err)
|
|
}
|
|
if err := <-done; err != nil && err != http.ErrServerClosed {
|
|
t.Fatalf("serve returned %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClientIPIgnoresUntrustedForwardedHeaders(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "/api/client/pair", nil)
|
|
r.RemoteAddr = "198.51.100.20:4040"
|
|
r.Header.Set("X-Forwarded-For", "203.0.113.8")
|
|
if got, want := s.clientIP(r), "198.51.100.20"; got != want {
|
|
t.Fatalf("client IP = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestClientIPUsesTrustedProxyHeadersOnlyForTrustedPeer(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.TrustedProxies = []string{"127.0.0.1/32"}
|
|
s, err := newServerForTest(t, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "/api/client/pair", nil)
|
|
r.RemoteAddr = "127.0.0.1:4040"
|
|
r.Header.Set("X-Forwarded-For", "203.0.113.8, 127.0.0.1")
|
|
if got, want := s.clientIP(r), "203.0.113.8"; got != want {
|
|
t.Fatalf("client IP = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterRecoversAfterWindow(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
limiter := newRateLimiter(func() time.Time { return now })
|
|
policy := RatePolicy{Limit: 2, Window: time.Minute}
|
|
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
|
|
t.Fatal("first attempt unexpectedly limited")
|
|
}
|
|
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
|
|
t.Fatal("second attempt unexpectedly limited")
|
|
}
|
|
if allowed, retryAfter := limiter.Allow("198.51.100.1", policy); allowed || retryAfter <= 0 {
|
|
t.Fatalf("third attempt = allowed:%t retry:%s, want limited with retry", allowed, retryAfter)
|
|
}
|
|
now = now.Add(time.Minute + time.Second)
|
|
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
|
|
t.Fatal("attempt after window remained limited")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterMemoryIsBounded(t *testing.T) {
|
|
limiter := newRateLimiter(nil)
|
|
policy := RatePolicy{Limit: 1, Window: time.Hour}
|
|
for i := 0; i < maxRateLimitBuckets+100; i++ {
|
|
if allowed, _ := limiter.Allow(strconvItoa(i), policy); !allowed {
|
|
t.Fatalf("new bucket %d unexpectedly limited", i)
|
|
}
|
|
}
|
|
if got := len(limiter.buckets); got > maxRateLimitBuckets {
|
|
t.Fatalf("rate buckets = %d, want <= %d", got, maxRateLimitBuckets)
|
|
}
|
|
}
|
|
|
|
func TestSyncPushRejectsOversizedJSONBody(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Limits.MaxJSONBody = 64
|
|
s, err := newServerForTest(t, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
|
|
|
|
overSizedJSON := append([]byte(`{"ops":[],"padding":"`), bytes.Repeat([]byte("x"), 65)...)
|
|
overSizedJSON = append(overSizedJSON, []byte(`"}`)...)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", bytes.NewReader(overSizedJSON))
|
|
req.Header.Set("Authorization", "Bearer token-a")
|
|
res := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(res, req)
|
|
if res.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %d, want %d: %s", res.Code, http.StatusRequestEntityTooLarge, res.Body.String())
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["code"] != "request_too_large" {
|
|
t.Fatalf("error body = %#v, want stable request_too_large code", body)
|
|
}
|
|
}
|
|
|
|
func TestSyncPushRejectsOperationCountAboveLimit(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Limits.MaxPushOperations = 1
|
|
s, err := newServerForTest(t, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
|
|
|
|
body := syncPushBody("device-a", "op-1", "")
|
|
body["ops"] = append(body["ops"].([]map[string]interface{}), map[string]interface{}{
|
|
"op_id": "op-2", "entity_type": "file", "entity_id": "Docs/two.txt", "op_type": "create",
|
|
"payload_json": `{"path":"Docs/two.txt","content":"two"}`, "created_at": "2026-07-10T00:00:00Z",
|
|
})
|
|
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", body)
|
|
if status != http.StatusRequestEntityTooLarge || response["code"] != "too_many_operations" {
|
|
t.Fatalf("status=%d body=%#v, want 413 too_many_operations", status, response)
|
|
}
|
|
}
|
|
|
|
func TestSyncPushRejectsTrailingJSONAndOversizedPayload(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Limits.MaxPayloadJSON = 32
|
|
s, err := newServerForTest(t, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
|
|
|
|
data, err := json.Marshal(syncPushBody("device-a", "op-trailing", ""))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", bytes.NewReader(append(data, []byte(` {}`)...)))
|
|
req.Header.Set("Authorization", "Bearer token-a")
|
|
res := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(res, req)
|
|
if res.Code != http.StatusBadRequest || !bytes.Contains(res.Body.Bytes(), []byte(`"trailing_json"`)) {
|
|
t.Fatalf("trailing JSON status=%d body=%s", res.Code, res.Body.String())
|
|
}
|
|
|
|
body := syncPushBody("device-a", "op-large-payload", "")
|
|
body["ops"].([]map[string]interface{})[0]["payload_json"] = `{"path":"Docs/large.txt","content":"this is intentionally longer than the configured payload bound"}`
|
|
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", body)
|
|
if status != http.StatusRequestEntityTooLarge || response["code"] != "payload_too_large" {
|
|
t.Fatalf("payload limit status=%d body=%#v", status, response)
|
|
}
|
|
}
|
|
|
|
func TestSyncPullPaginationHasNoGapsOrRepeats(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Limits.MaxPullPage = 2
|
|
s, err := newServerForTest(t, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
|
|
for _, opID := range []string{"op-1", "op-2", "op-3", "op-4", "op-5"} {
|
|
if status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", opID, "")); status != http.StatusOK {
|
|
t.Fatalf("push %s status=%d body=%#v", opID, status, response)
|
|
}
|
|
}
|
|
|
|
cursor := 0
|
|
var sequences []int
|
|
for page := 0; page < 3; page++ {
|
|
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/pull", "token-a", map[string]int{"since_sequence": cursor, "page_limit": 2})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("pull page %d status=%d body=%#v", page, status, response)
|
|
}
|
|
for _, raw := range response["ops"].([]interface{}) {
|
|
sequences = append(sequences, int(raw.(map[string]interface{})["server_sequence"].(float64)))
|
|
}
|
|
cursor = int(response["page_last_sequence"].(float64))
|
|
if !response["has_more"].(bool) {
|
|
break
|
|
}
|
|
}
|
|
if got, want := len(sequences), 5; got != want {
|
|
t.Fatalf("sequences=%v, want five ordered values", sequences)
|
|
}
|
|
for i, sequence := range sequences {
|
|
if sequence != i+1 {
|
|
t.Fatalf("sequences=%v, want [1 2 3 4 5]", sequences)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBlobOwnershipPreventsCrossVaultDownload(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertSyncUser(t, s, "user-b")
|
|
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
|
|
insertScopedSyncDevice(t, s, "device-b", "user-b", "vault-b", "token-b")
|
|
|
|
status, uploaded := uploadBlob(t, s, "token-a", []byte("private blob"))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("upload status=%d body=%#v", status, uploaded)
|
|
}
|
|
sha := uploaded["sha256"].(string)
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/blobs/"+sha, nil)
|
|
request.Header.Set("Authorization", "Bearer token-b")
|
|
response := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(response, request)
|
|
if response.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-vault download status=%d, want 404: %s", response.Code, response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBlobLimitAndQuotaRejectWithoutResidualFile(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Limits.MaxBlobBytes = 8
|
|
cfg.Limits.MaxVaultBlobBytes = 8
|
|
s, err := newServerForTest(t, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
|
|
|
|
tooLarge := []byte("012345678")
|
|
status, body := uploadBlob(t, s, "token-a", tooLarge)
|
|
if status != http.StatusRequestEntityTooLarge || body["code"] != "blob_too_large" {
|
|
t.Fatalf("file limit status=%d body=%#v", status, body)
|
|
}
|
|
sum := sha256.Sum256(tooLarge)
|
|
path := blobPath(s.blobsDir, hex.EncodeToString(sum[:]))
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("rejected oversized blob left physical file: %v", err)
|
|
}
|
|
|
|
status, body = uploadBlob(t, s, "token-a", []byte("12345678"))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("first quota upload status=%d body=%#v", status, body)
|
|
}
|
|
quotaCandidate := []byte("abcdefgh")
|
|
status, body = uploadBlob(t, s, "token-a", quotaCandidate)
|
|
if status != http.StatusRequestEntityTooLarge || body["code"] != "quota_exceeded" {
|
|
t.Fatalf("quota status=%d body=%#v", status, body)
|
|
}
|
|
sum = sha256.Sum256(quotaCandidate)
|
|
if _, err := os.Stat(blobPath(s.blobsDir, hex.EncodeToString(sum[:]))); !os.IsNotExist(err) {
|
|
t.Fatalf("quota-rejected blob left physical file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBlobUploadIsIdempotentWithinScope(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
|
|
|
|
data := []byte("same content")
|
|
_, first := uploadBlob(t, s, "token-a", data)
|
|
_, second := uploadBlob(t, s, "token-a", data)
|
|
if first["sha256"] != second["sha256"] || first["size"] != second["size"] {
|
|
t.Fatalf("idempotent uploads differ: first=%#v second=%#v", first, second)
|
|
}
|
|
var refs int
|
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_blob_refs WHERE user_id=? AND vault_id=?", "user-a", "vault-a").Scan(&refs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if refs != 1 {
|
|
t.Fatalf("blob refs = %d, want one", refs)
|
|
}
|
|
}
|
|
|
|
func TestRevokedDeviceCannotUseBlobEndpoints(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
|
|
status, uploaded := uploadBlob(t, s, "token-a", []byte("before revoke"))
|
|
if status != http.StatusOK {
|
|
t.Fatalf("upload status=%d body=%#v", status, uploaded)
|
|
}
|
|
if err := s.revokeDevice("device-a", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/blobs/"+uploaded["sha256"].(string), nil)
|
|
request.Header.Set("Authorization", "Bearer token-a")
|
|
response := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(response, request)
|
|
if response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("revoked blob download status=%d, want 401", response.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminKeysNeverReturnPlaintextCredential(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "secret-device-token")
|
|
token, _, err := s.createSession(sessionScopeAdmin, "admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := httptest.NewRequest(http.MethodGet, "/admin/api/keys", nil)
|
|
request.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
|
|
response := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(response, request)
|
|
if response.Code != http.StatusOK || bytes.Contains(response.Body.Bytes(), []byte("secret-device-token")) || bytes.Contains(response.Body.Bytes(), []byte("api_key")) {
|
|
t.Fatalf("admin keys leaked a credential: status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHealthReportsDegradedDatabase(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.SetupRoutes()
|
|
if err := s.db.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
|
response := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(response, request)
|
|
if response.Code != http.StatusServiceUnavailable || !bytes.Contains(response.Body.Bytes(), []byte(`"database_reachable":false`)) {
|
|
t.Fatalf("readiness after db close: status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRetentionDoesNotPruneOperations(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.Retention.IdempotencyHours = 1
|
|
cfg.Retention.AuditDays = 1
|
|
s, err := newServerForTest(t, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
|
|
if status, body := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-retained", "")); status != http.StatusOK {
|
|
t.Fatalf("push status=%d body=%#v", status, body)
|
|
}
|
|
if err := s.CleanupRetention(time.Now().UTC().Add(48 * time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var ops int
|
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&ops); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ops != 1 {
|
|
t.Fatalf("retention removed sync operations: %d", ops)
|
|
}
|
|
}
|
|
|
|
func TestWebSessionSurvivesServerRestartAndLogoutInvalidatesIt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "server.db")
|
|
dataDir := filepath.Join(dir, "data")
|
|
s, err := NewServer(dbPath, dataDir, DefaultConfig())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token, csrf, err := s.createSession(sessionScopeUser, "user-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
restarted, err := NewServer(dbPath, dataDir, DefaultConfig())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer restarted.Close()
|
|
restarted.SetupRoutes()
|
|
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
|
req.AddCookie(&http.Cookie{Name: "user_session", Value: token})
|
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
|
req.Header.Set("X-CSRF-Token", csrf)
|
|
res := httptest.NewRecorder()
|
|
restarted.Handler().ServeHTTP(res, req)
|
|
if res.Code != http.StatusFound {
|
|
t.Fatalf("logout status=%d body=%s", res.Code, res.Body.String())
|
|
}
|
|
if _, ok := restarted.loadSession(token, sessionScopeUser); ok {
|
|
t.Fatal("logout did not invalidate server-side session")
|
|
}
|
|
}
|
|
|
|
func TestAdminMutationRejectsMissingCSRFToken(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
missing := httptest.NewRequest(http.MethodDelete, "/admin/api/keys/missing", nil)
|
|
missing.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
|
|
missingResult := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(missingResult, missing)
|
|
if missingResult.Code != http.StatusForbidden {
|
|
t.Fatalf("missing csrf status=%d, want 403", missingResult.Code)
|
|
}
|
|
|
|
valid := httptest.NewRequest(http.MethodDelete, "/admin/api/keys/missing", nil)
|
|
valid.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
|
|
valid.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
|
valid.Header.Set("X-CSRF-Token", csrf)
|
|
validResult := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(validResult, valid)
|
|
if validResult.Code != http.StatusOK {
|
|
t.Fatalf("valid csrf status=%d body=%s", validResult.Code, validResult.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminUserDeletionIsTransactionalAcrossOwnedRows(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.SetupRoutes()
|
|
insertSyncUser(t, s, "user-a")
|
|
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
|
|
if status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-a", "")); status != http.StatusOK {
|
|
t.Fatalf("push status=%d body=%#v", status, response)
|
|
}
|
|
adminToken, csrf, err := s.createSession(sessionScopeAdmin, "admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := httptest.NewRequest(http.MethodDelete, "/admin/api/users/user-a", nil)
|
|
request.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
|
|
request.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
|
request.Header.Set("X-CSRF-Token", csrf)
|
|
response := httptest.NewRecorder()
|
|
s.Handler().ServeHTTP(response, request)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("delete status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
for table, where := range map[string]string{
|
|
"server_users": "id='user-a'",
|
|
"server_devices": "user_id='user-a'",
|
|
"server_ops": "user_id='user-a'",
|
|
"server_blob_refs": "user_id='user-a'",
|
|
} {
|
|
var count int
|
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM " + table + " WHERE " + where).Scan(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("%s still has %d rows after user deletion", table, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResetTokenIsHashedAndSingleUse(t *testing.T) {
|
|
s, err := newTestServer(t)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
insertPairableUser(t, s, "user-a", "alice", "correct horse battery staple")
|
|
token, err := issueEmailToken(s.db, "user-a", "reset", time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stored string
|
|
if err := s.db.QueryRow("SELECT token_hash FROM server_email_tokens WHERE user_id=?", "user-a").Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored == token || stored != emailTokenHash(token) {
|
|
t.Fatalf("stored reset credential is not a token hash: %q", stored)
|
|
}
|
|
if _, err := s.resetPasswordWithToken(token, "a new secure password"); err != nil {
|
|
t.Fatalf("first reset: %v", err)
|
|
}
|
|
if _, err := s.resetPasswordWithToken(token, "another secure password"); err != errResetTokenInvalid {
|
|
t.Fatalf("second reset err=%v, want invalid token", err)
|
|
}
|
|
}
|