feat: implement security, discovery, history, file transfer, web UI (P0-P3)
This commit is contained in:
+259
-4
@@ -1,35 +1,77 @@
|
||||
// Package server provides the HTTP endpoint that receives clipboard payloads
|
||||
// from remote clip-sync peers.
|
||||
// from remote clip-sync peers, plus a small local monitoring UI.
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/history"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/notify"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/transfer"
|
||||
)
|
||||
|
||||
// Options configures a Server.
|
||||
type Options struct {
|
||||
Addr string
|
||||
SharedKey string
|
||||
AllowedOrigins []string
|
||||
MaxBodyBytes int64
|
||||
History *history.Store
|
||||
Notifier notify.Notifier
|
||||
ReceiveFiles bool
|
||||
ReceiveDir string
|
||||
}
|
||||
|
||||
// Stats holds lightweight counters exposed via the monitoring UI.
|
||||
type Stats struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ReceivedCount int64 `json:"received_count"`
|
||||
LastReceivedAt time.Time `json:"last_received_at"`
|
||||
LastReceivedBy string `json:"last_received_by"`
|
||||
LastReceivedLen int `json:"last_received_len"`
|
||||
}
|
||||
|
||||
// Server receives clips from peers and writes them to the local clipboard.
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
clipboard clipboard.Clipboard
|
||||
filter *dedup.Filter
|
||||
opts Options
|
||||
allowed map[string]struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
stats Stats
|
||||
}
|
||||
|
||||
// New creates a Server that listens on addr.
|
||||
func New(addr string, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
|
||||
// New creates a Server that listens on opts.Addr.
|
||||
func New(opts Options, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
|
||||
s := &Server{
|
||||
clipboard: cb,
|
||||
filter: filter,
|
||||
opts: opts,
|
||||
allowed: make(map[string]struct{}),
|
||||
stats: Stats{StartedAt: time.Now()},
|
||||
}
|
||||
for _, o := range opts.AllowedOrigins {
|
||||
s.allowed[o] = struct{}{}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/clip", s.handleClip)
|
||||
mux.HandleFunc("/file", s.handleFile)
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
mux.HandleFunc("/history", s.handleHistory)
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
|
||||
s.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Addr: opts.Addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
@@ -41,17 +83,36 @@ func (s *Server) ListenAndServe() error {
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
// ListenAndServeTLS starts the HTTPS server with the given cert/key files.
|
||||
func (s *Server) ListenAndServeTLS(certFile, keyFile string) error {
|
||||
return s.httpServer.ListenAndServeTLS(certFile, keyFile)
|
||||
}
|
||||
|
||||
// Close gracefully shuts down the HTTP server.
|
||||
func (s *Server) Close() error {
|
||||
return s.httpServer.Close()
|
||||
}
|
||||
|
||||
// Stats returns a snapshot of the server statistics.
|
||||
func (s *Server) Stats() Stats {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.stats
|
||||
}
|
||||
|
||||
func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bound the request body to prevent memory-exhaustion / DoS.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes)
|
||||
|
||||
var p dedup.Payload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "bad request: invalid JSON", http.StatusBadRequest)
|
||||
@@ -63,6 +124,14 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Origin validation: reject payloads from unknown origins if configured.
|
||||
if len(s.allowed) > 0 {
|
||||
if _, ok := s.allowed[p.Origin]; !ok {
|
||||
http.Error(w, "forbidden: unknown origin", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if s.filter.ShouldIgnore(p) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
@@ -75,5 +144,191 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.filter.MarkWritten(p.Text)
|
||||
s.recordReceive(p)
|
||||
|
||||
if s.opts.History != nil {
|
||||
s.opts.History.Append(history.Entry{Text: p.Text, Origin: p.Origin, Ts: p.Ts})
|
||||
}
|
||||
|
||||
if s.opts.Notifier != nil {
|
||||
if err := s.opts.Notifier.Notify("clip-sync", "Clip reçu de "+p.Origin); err != nil {
|
||||
log.Printf("server: notify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) recordReceive(p dedup.Payload) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stats.ReceivedCount++
|
||||
s.stats.LastReceivedAt = time.Now()
|
||||
s.stats.LastReceivedBy = p.Origin
|
||||
s.stats.LastReceivedLen = len(p.Text)
|
||||
}
|
||||
|
||||
// authorized checks the shared-key bearer token (constant-time comparison).
|
||||
// It writes the error response and returns false when authentication fails.
|
||||
func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.opts.SharedKey == "" {
|
||||
return true
|
||||
}
|
||||
got := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(got) < len(prefix) || !equalFoldSubtle(got[:len(prefix)], prefix) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
token := got[len(prefix):]
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(s.opts.SharedKey)) != 1 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleFile receives a binary payload and, if enabled, writes it to disk.
|
||||
func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !s.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
if !s.opts.ReceiveFiles {
|
||||
http.Error(w, "file reception disabled", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes)
|
||||
|
||||
var f transfer.File
|
||||
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
|
||||
http.Error(w, "bad request: invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if f.Data == "" || f.Name == "" {
|
||||
http.Error(w, "bad request: missing name or data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(s.allowed) > 0 {
|
||||
if _, ok := s.allowed[f.Origin]; !ok {
|
||||
http.Error(w, "forbidden: unknown origin", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dest, err := f.SaveTo(s.opts.ReceiveDir)
|
||||
if err != nil {
|
||||
log.Printf("server: file save: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("server: received file %q from %q -> %s", f.Name, f.Origin, dest)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ok": true,
|
||||
"stats": s.Stats(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if s.opts.History == nil {
|
||||
_ = json.NewEncoder(w).Encode([]history.Entry{})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(s.opts.History.List())
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(indexHTML))
|
||||
}
|
||||
|
||||
// equalFoldSubtle is a constant-time ASCII case-insensitive comparison. It is
|
||||
// only used to match the "Bearer " scheme prefix, so a length difference is
|
||||
// acceptable to leak.
|
||||
func equalFoldSubtle(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(a); i++ {
|
||||
ca, cb := a[i], b[i]
|
||||
if ca >= 'A' && ca <= 'Z' {
|
||||
ca += 'a' - 'A'
|
||||
}
|
||||
if cb >= 'A' && cb <= 'Z' {
|
||||
cb += 'a' - 'A'
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const indexHTML = `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>clip-sync</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
.card { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
||||
.kv { display: flex; justify-content: space-between; padding: .2rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
li { padding: .35rem .5rem; border-bottom: 1px solid #eaeef2; font-family: monospace; font-size: .85rem; word-break: break-all; }
|
||||
.origin { color: #0969da; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>clip-sync — monitoring</h1>
|
||||
<div class="card">
|
||||
<div class="kv"><span>État</span><strong id="status">…</strong></div>
|
||||
<div class="kv"><span>Clips reçus</span><span id="received">…</span></div>
|
||||
<div class="kv"><span>Dernier clip</span><span id="last">…</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Historique récent</h2>
|
||||
<ul id="history"><li>Chargement…</li></ul>
|
||||
</div>
|
||||
<script>
|
||||
async function refresh() {
|
||||
try {
|
||||
const h = await (await fetch('/health')).json();
|
||||
document.getElementById('status').textContent = h.ok ? 'OK' : 'ERREUR';
|
||||
document.getElementById('received').textContent = h.stats.received_count;
|
||||
document.getElementById('last').textContent = h.stats.last_received_by
|
||||
? (h.stats.last_received_by + ' (' + h.stats.last_received_len + ' octets)') : '—';
|
||||
} catch (e) { document.getElementById('status').textContent = 'indisponible'; }
|
||||
|
||||
try {
|
||||
const entries = await (await fetch('/history')).json();
|
||||
const ul = document.getElementById('history');
|
||||
ul.innerHTML = '';
|
||||
[...entries].reverse().slice(0, 20).forEach(e => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = '<span class="origin">' + escapeHtml(e.origin || 'local') + '</span> ' + escapeHtml(e.text);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
refresh();
|
||||
setInterval(refresh, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
+121
-52
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
||||
@@ -16,29 +17,37 @@ type mockClipboard struct {
|
||||
text string
|
||||
}
|
||||
|
||||
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
|
||||
func (m *mockClipboard) Write(s string) error { m.text = s; return nil }
|
||||
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
|
||||
func (m *mockClipboard) Write(s string) error { m.text = s; return nil }
|
||||
|
||||
func TestHandleClipAcceptsValidPayload(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
func newTestServer(cb *mockClipboard, opts Options) *Server {
|
||||
filter := dedup.NewFilter("my-machine")
|
||||
srv := New(":0", cb, filter)
|
||||
return New(opts, cb, filter)
|
||||
}
|
||||
|
||||
payload := dedup.Payload{
|
||||
Text: "test text",
|
||||
Ts: 1690000000000000000,
|
||||
Origin: "other-machine",
|
||||
}
|
||||
func doClip(t *testing.T, s *Server, payload dedup.Payload, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.handleClip(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
srv.handleClip(w, req)
|
||||
func TestHandleClipAcceptsValidPayload(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||
|
||||
w := doClip(t, srv, dedup.Payload{
|
||||
Text: "test text", Ts: 1690000000000000000, Origin: "other-machine",
|
||||
}, nil)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
|
||||
@@ -50,32 +59,18 @@ func TestHandleClipAcceptsValidPayload(t *testing.T) {
|
||||
|
||||
func TestHandleClipIgnoresEcho(t *testing.T) {
|
||||
cb := &mockClipboard{text: "existing"}
|
||||
filter := dedup.NewFilter("my-machine")
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||
|
||||
// Simulate we just wrote "echo text" locally.
|
||||
filter.MarkWritten("echo text")
|
||||
srv.filter.MarkWritten("echo text")
|
||||
|
||||
srv := New(":0", cb, filter)
|
||||
|
||||
payload := dedup.Payload{
|
||||
Text: "echo text", // same text we just wrote
|
||||
Ts: 1690000000000000000,
|
||||
Origin: "other-machine",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleClip(w, req)
|
||||
w := doClip(t, srv, dedup.Payload{
|
||||
Text: "echo text", Ts: 1690000000000000000, Origin: "other-machine",
|
||||
}, nil)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
|
||||
}
|
||||
// Clipboard should NOT be overwritten.
|
||||
if cb.text != "existing" {
|
||||
t.Errorf("clipboard = %q, want \"existing\" (should not be overwritten)", cb.text)
|
||||
}
|
||||
@@ -83,8 +78,7 @@ func TestHandleClipIgnoresEcho(t *testing.T) {
|
||||
|
||||
func TestHandleClipRejectsInvalidMethod(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
filter := dedup.NewFilter("my-machine")
|
||||
srv := New(":0", cb, filter)
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/clip", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -98,23 +92,11 @@ func TestHandleClipRejectsInvalidMethod(t *testing.T) {
|
||||
|
||||
func TestHandleClipRejectsEmptyText(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
filter := dedup.NewFilter("my-machine")
|
||||
srv := New(":0", cb, filter)
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||
|
||||
payload := dedup.Payload{
|
||||
Text: "",
|
||||
Ts: 1690000000000000000,
|
||||
Origin: "other-machine",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleClip(w, req)
|
||||
w := doClip(t, srv, dedup.Payload{
|
||||
Text: "", Ts: 1690000000000000000, Origin: "other-machine",
|
||||
}, nil)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
@@ -123,8 +105,7 @@ func TestHandleClipRejectsEmptyText(t *testing.T) {
|
||||
|
||||
func TestHandleClipRejectsInvalidJSON(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
filter := dedup.NewFilter("my-machine")
|
||||
srv := New(":0", cb, filter)
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader([]byte("not json")))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -136,7 +117,95 @@ func TestHandleClipRejectsInvalidJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClipRequiresSharedKey(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, SharedKey: "secret"})
|
||||
|
||||
// No auth header → unauthorized.
|
||||
w := doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "other-machine"}, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d (missing key)", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// Wrong key → unauthorized.
|
||||
w = doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "other-machine"},
|
||||
map[string]string{"Authorization": "Bearer wrong"})
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d (wrong key)", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// Correct key → accepted.
|
||||
w = doClip(t, srv, dedup.Payload{Text: "x", Ts: 2, Origin: "other-machine"},
|
||||
map[string]string{"Authorization": "Bearer secret"})
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d (correct key)", w.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClipRejectsUnknownOrigin(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, AllowedOrigins: []string{"trusted-host"}})
|
||||
|
||||
w := doClip(t, srv, dedup.Payload{Text: "x", Ts: 1, Origin: "evil-host"}, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClipEnforcesBodyLimit(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 16})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clip", strings.NewReader(`{"text":"`+strings.Repeat("a", 1024)+`"}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleClip(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d (oversized body)", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileReception(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
dir := t.TempDir()
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20, ReceiveFiles: true, ReceiveDir: dir})
|
||||
|
||||
body := `{"name":"hello.txt","mime":"text/plain","data":"aGVsbG8=","ts":1,"origin":"other-machine"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/file", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleFile(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("expected a file in %s, got %d entries (err=%v)", dir, len(entries), err)
|
||||
}
|
||||
data, _ := os.ReadFile(dir + "/" + entries[0].Name())
|
||||
if string(data) != "hello" {
|
||||
t.Errorf("file content = %q, want \"hello\"", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileDisabled(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20}) // ReceiveFiles defaults false
|
||||
|
||||
body := `{"name":"x","data":"eA==","ts":1,"origin":"other-machine"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/file", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleFile(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Ensure clipboard.New() won't be called in test (mock is used directly).
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user