server and web app
This commit is contained in:
390
apps/server/internal/httpserver/api_handlers_test.go
Normal file
390
apps/server/internal/httpserver/api_handlers_test.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||
"github.com/tim/cairnquire/apps/server/internal/sync"
|
||||
)
|
||||
|
||||
type apiTestServer struct {
|
||||
handler http.Handler
|
||||
auth *auth.Service
|
||||
docs *docs.Service
|
||||
userID string
|
||||
root string
|
||||
}
|
||||
|
||||
func newAPITestServer(t *testing.T) *apiTestServer {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "content")
|
||||
storeDir := filepath.Join(root, "files")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatalf("create source dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nInitial"), 0o644); err != nil {
|
||||
t.Fatalf("write source fixture: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(root, "db.sqlite")})
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
contentStore, err := store.New(storeDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create content store: %v", err)
|
||||
}
|
||||
docRepo := docs.NewRepository(db.SQL())
|
||||
docService := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), docRepo, logger)
|
||||
if _, err := docService.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("sync source fixture: %v", err)
|
||||
}
|
||||
|
||||
authService, err := auth.NewService(auth.NewRepository(db.SQL()), "http://localhost:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("create auth service: %v", err)
|
||||
}
|
||||
user, err := authService.RegisterPasswordUser(ctx, "editor@example.com", "Editor", "very secure password", string(auth.RoleEditor))
|
||||
if err != nil {
|
||||
t.Fatalf("create test user: %v", err)
|
||||
}
|
||||
|
||||
syncRepo := sync.NewRepository(db.SQL())
|
||||
handler, err := New(Dependencies{
|
||||
Config: config.Config{
|
||||
Content: config.ContentConfig{
|
||||
SourceDir: sourceDir,
|
||||
StoreDir: storeDir,
|
||||
},
|
||||
Web: config.WebConfig{DistDir: filepath.Join(root, "web-dist")},
|
||||
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
||||
},
|
||||
Logger: logger,
|
||||
Documents: docService,
|
||||
Repository: docRepo,
|
||||
ContentStore: contentStore,
|
||||
Hub: realtime.NewHub(logger),
|
||||
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
|
||||
SyncRepo: syncRepo,
|
||||
Auth: authService,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create http server: %v", err)
|
||||
}
|
||||
|
||||
return &apiTestServer{
|
||||
handler: handler,
|
||||
auth: authService,
|
||||
docs: docService,
|
||||
userID: user.ID,
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
|
||||
t.Helper()
|
||||
|
||||
created, err := s.auth.CreateAPIKey(context.Background(), s.userID, "test token", scopes, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create api token: %v", err)
|
||||
}
|
||||
return created.Token
|
||||
}
|
||||
|
||||
func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
|
||||
body, contentType := multipartBody(t, "file", `unsafe"name.txt`, []byte("plain attachment\n"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(recorder, request)
|
||||
|
||||
response := recorder.Result()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload status = %d, want %d; body=%s", response.StatusCode, http.StatusCreated, recorder.Body.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Hash string `json:"hash"`
|
||||
ContentType string `json:"contentType"`
|
||||
AttachmentURL string `json:"attachmentUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode upload response: %v", err)
|
||||
}
|
||||
if payload.Hash == "" {
|
||||
t.Fatal("expected upload response hash")
|
||||
}
|
||||
if payload.ContentType != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("contentType = %q, want text/plain; charset=utf-8", payload.ContentType)
|
||||
}
|
||||
if payload.AttachmentURL != "/attachments/"+payload.Hash {
|
||||
t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash)
|
||||
}
|
||||
|
||||
download := httptest.NewRequest(http.MethodGet, payload.AttachmentURL, nil)
|
||||
downloadRecorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(downloadRecorder, download)
|
||||
|
||||
downloadResponse := downloadRecorder.Result()
|
||||
if downloadResponse.StatusCode != http.StatusOK {
|
||||
t.Fatalf("download status = %d, want %d", downloadResponse.StatusCode, http.StatusOK)
|
||||
}
|
||||
if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType {
|
||||
t.Fatalf("download content-type = %q, want %q", got, payload.ContentType)
|
||||
}
|
||||
if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.txt"` {
|
||||
t.Fatalf("content-disposition = %q, want sanitized filename", got)
|
||||
}
|
||||
if downloadRecorder.Body.String() != "plain attachment\n" {
|
||||
t.Fatalf("download body = %q", downloadRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||
|
||||
body, contentType := multipartBody(t, "file", "note.txt", []byte("plain attachment\n"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||
|
||||
request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("sync init status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ServerSnapshot []sync.FileEntry `json:"serverSnapshot"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode sync init response: %v", err)
|
||||
}
|
||||
if payload.SnapshotID == "" {
|
||||
t.Fatal("expected snapshot id")
|
||||
}
|
||||
if len(payload.ServerSnapshot) != 1 || payload.ServerSnapshot[0].Path != "hello.md" {
|
||||
t.Fatalf("server snapshot = %#v, want hello.md entry", payload.ServerSnapshot)
|
||||
}
|
||||
|
||||
fetch := httptest.NewRequest(http.MethodGet, "/api/content/"+payload.ServerSnapshot[0].Hash, nil)
|
||||
fetch.Header.Set("Authorization", "Bearer "+token)
|
||||
fetchRecorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(fetchRecorder, fetch)
|
||||
|
||||
if fetchRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("content fetch status = %d, want %d", fetchRecorder.Code, http.StatusOK)
|
||||
}
|
||||
if fetchRecorder.Body.String() != "# Hello\n\nInitial" {
|
||||
t.Fatalf("content fetch body = %q", fetchRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISyncInitRejectsTokenWithoutSyncWriteScope(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
|
||||
request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
|
||||
|
||||
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
|
||||
initRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
initRecorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(initRecorder, initRequest)
|
||||
if initRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
|
||||
}
|
||||
|
||||
var initPayload struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
}
|
||||
if err := json.Unmarshal(initRecorder.Body.Bytes(), &initPayload); err != nil {
|
||||
t.Fatalf("decode sync init response: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer update"), 0o644); err != nil {
|
||||
t.Fatalf("write server update: %v", err)
|
||||
}
|
||||
|
||||
deltaRequest := jsonRequest(http.MethodPost, "/api/sync/delta", map[string]any{
|
||||
"snapshotId": initPayload.SnapshotID,
|
||||
"clientDelta": []sync.Change{},
|
||||
})
|
||||
deltaRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
deltaRecorder := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(deltaRecorder, deltaRequest)
|
||||
|
||||
if deltaRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("sync delta status = %d, want %d; body=%s", deltaRecorder.Code, http.StatusOK, deltaRecorder.Body.String())
|
||||
}
|
||||
|
||||
var deltaPayload struct {
|
||||
ServerDelta []sync.Change `json:"serverDelta"`
|
||||
Conflicts []sync.Conflict `json:"conflicts"`
|
||||
}
|
||||
if err := json.Unmarshal(deltaRecorder.Body.Bytes(), &deltaPayload); err != nil {
|
||||
t.Fatalf("decode sync delta response: %v", err)
|
||||
}
|
||||
if len(deltaPayload.ServerDelta) != 1 {
|
||||
t.Fatalf("serverDelta = %#v, want one update", deltaPayload.ServerDelta)
|
||||
}
|
||||
change := deltaPayload.ServerDelta[0]
|
||||
if change.Type != sync.ChangeUpdate || change.Path != "hello.md" || change.Hash == "" {
|
||||
t.Fatalf("server delta change = %#v, want update for hello.md with hash", change)
|
||||
}
|
||||
if len(deltaPayload.Conflicts) != 0 {
|
||||
t.Fatalf("conflicts = %#v, want none", deltaPayload.Conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
|
||||
if documents.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var list struct {
|
||||
Documents []struct {
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
} `json:"documents"`
|
||||
}
|
||||
if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("decode documents: %v", err)
|
||||
}
|
||||
if len(list.Documents) != 1 {
|
||||
t.Fatalf("documents = %#v, want one document", list.Documents)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer edit"), 0o644); err != nil {
|
||||
t.Fatalf("write server edit: %v", err)
|
||||
}
|
||||
|
||||
save := jsonRequest(http.MethodPost, "/api/documents/hello", map[string]string{
|
||||
"content": "# Hello\n\nClient edit",
|
||||
"baseHash": list.Documents[0].Hash,
|
||||
})
|
||||
save.Header.Set("Authorization", "Bearer "+token)
|
||||
saveRecorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(saveRecorder, save)
|
||||
|
||||
if saveRecorder.Code != http.StatusConflict {
|
||||
t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusConflict, saveRecorder.Body.String())
|
||||
}
|
||||
|
||||
var conflict struct {
|
||||
Status string `json:"status"`
|
||||
Path string `json:"path"`
|
||||
BaseHash string `json:"baseHash"`
|
||||
CurrentHash string `json:"currentHash"`
|
||||
CurrentContent string `json:"currentContent"`
|
||||
}
|
||||
if err := json.Unmarshal(saveRecorder.Body.Bytes(), &conflict); err != nil {
|
||||
t.Fatalf("decode conflict response: %v", err)
|
||||
}
|
||||
if conflict.Status != "conflict" || conflict.Path != "hello.md" {
|
||||
t.Fatalf("conflict payload = %#v, want conflict for hello.md", conflict)
|
||||
}
|
||||
if conflict.BaseHash != list.Documents[0].Hash {
|
||||
t.Fatalf("baseHash = %q, want %q", conflict.BaseHash, list.Documents[0].Hash)
|
||||
}
|
||||
if conflict.CurrentHash == "" || conflict.CurrentHash == conflict.BaseHash {
|
||||
t.Fatalf("currentHash = %q, baseHash = %q", conflict.CurrentHash, conflict.BaseHash)
|
||||
}
|
||||
if conflict.CurrentContent != "# Hello\n\nServer edit" {
|
||||
t.Fatalf("currentContent = %q", conflict.CurrentContent)
|
||||
}
|
||||
}
|
||||
|
||||
func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile(fieldName, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create multipart file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write multipart file: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
return body, writer.FormDataContentType()
|
||||
}
|
||||
|
||||
func jsonRequest(method, path string, payload any) *http.Request {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request
|
||||
}
|
||||
Reference in New Issue
Block a user