package web // handlers_files_test.go — Wave 1 (Plan 02) test implementation for FILE-01..03, FILE-06. // // TestFileUpload and TestFilesTab are activated (no t.Skip). // TestFileDownload, TestFileDelete, TestFileOwnership (download/delete routes) remain // as skipped stubs — those are wired in Plan 03. // // Pattern: mirrors handlers_tasks_test.go exactly — setupTestDB, loginUser, // preInsertUser, getCSRFToken, and testCSRFKey are reused from the existing // test files in the same package. import ( "bytes" "context" "errors" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "strings" "testing" "backend/internal/auth" "backend/internal/db/sqlc" "backend/internal/files" "github.com/jackc/pgx/v5/pgtype" ) // stubbedFileStorer is a no-op FileStorer for test injection. // deleteErr may be set to simulate S3 delete failures (TestFileDelete_S3Failure). type stubbedFileStorer struct { uploadedKey string deletedKey string deleteErr error } func (s *stubbedFileStorer) Upload(_ context.Context, key string, _ io.Reader) (string, int64, error) { s.uploadedKey = key return "application/octet-stream", 12, nil } func (s *stubbedFileStorer) Delete(_ context.Context, key string) error { s.deletedKey = key return s.deleteErr } func (s *stubbedFileStorer) PresignDownload(_ context.Context, _ string) (string, error) { return "https://example.com/signed?key=foo", nil } // Compile-time assertion: stubbedFileStorer satisfies the files.FileStorer interface. var _ files.FileStorer = (*stubbedFileStorer)(nil) // newFileTestRouter builds a router with FilesDeps wired for file handler tests. func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileStorer) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25} return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") } // ---- TestFileUpload (FILE-01, FILE-02) ---- // TestFileUpload verifies that POST /tablos/{id}/files uploads a file, // creates a DB row, and stores bytes in S3 (FILE-01 server-proxied upload, FILE-02). func TestFileUpload(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{} router := newFileTestRouter(q, store, stub) user := preInsertUser(t, ctx, q, "fileupload@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "Upload Test Tablo", Description: pgtype.Text{Valid: false}, Color: pgtype.Text{Valid: false}, }) if err != nil { t.Fatalf("InsertTablo: %v", err) } sessionVal, _, err := store.Create(ctx, user.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} // Build multipart body with a CSRF token. csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/files", []*http.Cookie{sessionCookie}) var body bytes.Buffer mw := multipart.NewWriter(&body) // Add CSRF field. _ = mw.WriteField("gorilla.csrf.Token", csrfToken) // Add file field. fw, err := mw.CreateFormFile("file", "hello.txt") if err != nil { t.Fatalf("CreateFormFile: %v", err) } _, _ = fw.Write([]byte("hello world!")) mw.Close() req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/files", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.AddCookie(sessionCookie) for _, c := range csrfCookies { req.AddCookie(c) } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) // Non-HTMX path: expect 303 redirect to /tablos/{id}/files. if rec.Code != http.StatusSeeOther { t.Fatalf("POST /tablos/{id}/files status = %d; want 303\nbody: %s", rec.Code, rec.Body.String()) } loc := rec.Header().Get("Location") if !strings.Contains(loc, "/files") { t.Errorf("redirect location = %q; want it to contain /files", loc) } // Verify a DB row was created. dbFiles, err := q.ListFilesByTablo(ctx, tablo.ID) if err != nil { t.Fatalf("ListFilesByTablo: %v", err) } if len(dbFiles) != 1 { t.Fatalf("expected 1 file row, got %d", len(dbFiles)) } if dbFiles[0].Filename != "hello.txt" { t.Errorf("filename = %q; want %q", dbFiles[0].Filename, "hello.txt") } // Verify S3 key starts with "files/{tablo_id}/" (D-04). if stub.uploadedKey == "" { t.Error("stub.uploadedKey is empty — Upload was not called") } expectedPrefix := "files/" + tablo.ID.String() + "/" if !strings.HasPrefix(stub.uploadedKey, expectedPrefix) { t.Errorf("S3 key = %q; want prefix %q", stub.uploadedKey, expectedPrefix) } } // TestFileUploadTooLarge verifies that POST with file body > MaxUploadMB returns 422 // with an error message containing "too large". func TestFileUploadTooLarge(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{} // Use 1 MB limit to keep test fast. authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1} router := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") user := preInsertUser(t, ctx, q, "filelarge@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "Large Upload Test Tablo", Description: pgtype.Text{Valid: false}, Color: pgtype.Text{Valid: false}, }) if err != nil { t.Fatalf("InsertTablo: %v", err) } sessionVal, _, err := store.Create(ctx, user.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/files", []*http.Cookie{sessionCookie}) // Build a multipart body that exceeds 1 MB. var body bytes.Buffer mw := multipart.NewWriter(&body) _ = mw.WriteField("gorilla.csrf.Token", csrfToken) fw, err := mw.CreateFormFile("file", "big.bin") if err != nil { t.Fatalf("CreateFormFile: %v", err) } // Write 2 MB of data. bigChunk := make([]byte, 2*1024*1024) _, _ = fw.Write(bigChunk) mw.Close() req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/files", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.AddCookie(sessionCookie) for _, c := range csrfCookies { req.AddCookie(c) } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("oversized upload status = %d; want 422", rec.Code) } if !strings.Contains(rec.Body.String(), "too large") { t.Errorf("response body = %q; want it to contain 'too large'", rec.Body.String()) } } // ---- TestFilesList (FILE-03) ---- // TestFilesList verifies that GET /tablos/{id}/files lists files with // original filename and size, newest first (FILE-03). func TestFilesList(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{} router := newFileTestRouter(q, store, stub) user := preInsertUser(t, ctx, q, "fileslist@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "List Test Tablo", Description: pgtype.Text{Valid: false}, Color: pgtype.Text{Valid: false}, }) if err != nil { t.Fatalf("InsertTablo: %v", err) } // Pre-insert a file row directly. _, err = q.InsertTabloFile(ctx, sqlc.InsertTabloFileParams{ TabloID: tablo.ID, S3Key: "files/" + tablo.ID.String() + "/test-uuid", Filename: "myfile.pdf", ContentType: "application/pdf", SizeBytes: 2048, }) if err != nil { t.Fatalf("InsertTabloFile: %v", err) } sessionVal, _, err := store.Create(ctx, user.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/files", nil) req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET /tablos/{id}/files status = %d; want 200", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "myfile.pdf") { t.Errorf("file list missing filename 'myfile.pdf'; body snippet: %.200s", body) } // Human-readable size should appear (2048 bytes = "2.0 KB"). if !strings.Contains(body, "KB") && !strings.Contains(body, "2048") { t.Errorf("file list missing size display; body snippet: %.200s", body) } } // ---- TestFilesTab (HTMX fragment) ---- // TestFilesTab verifies that GET /tablos/{id}/files with HX-Request: true // returns 200 + HTML fragment (not full Layout), and without HX-Request returns full page. func TestFilesTab(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{} router := newFileTestRouter(q, store, stub) user := preInsertUser(t, ctx, q, "filestab@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "Tab Test Tablo", Description: pgtype.Text{Valid: false}, Color: pgtype.Text{Valid: false}, }) if err != nil { t.Fatalf("InsertTablo: %v", err) } sessionVal, _, err := store.Create(ctx, user.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} // HX-Request path → should return fragment (no full layout). req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/files", nil) req.AddCookie(sessionCookie) req.Header.Set("HX-Request", "true") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("HTMX GET /tablos/{id}/files status = %d; want 200", rec.Code) } body := rec.Body.String() // Fragment should NOT contain the full Layout wrapper. if strings.Contains(body, "") } // Should contain the upload form. if !strings.Contains(body, `name="file"`) { t.Errorf("HTMX response missing upload form file input; body snippet: %.200s", body) } // Non-HTMX path → should return full page with Layout. req2 := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/files", nil) req2.AddCookie(sessionCookie) rec2 := httptest.NewRecorder() router.ServeHTTP(rec2, req2) if rec2.Code != http.StatusOK { t.Fatalf("non-HTMX GET /tablos/{id}/files status = %d; want 200", rec2.Code) } body2 := rec2.Body.String() if !strings.Contains(body2, "; body snippet: %.200s", body2) } } // ---- TestFileDownload (FILE-04) ---- // TestFileDownload verifies that GET /tablos/{id}/files/{file_id}/download // returns a 302 redirect to a signed time-limited URL (FILE-04). func TestFileDownload(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{} router := newFileTestRouter(q, store, stub) user := preInsertUser(t, ctx, q, "filedownload@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "Download Test Tablo", }) if err != nil { t.Fatalf("InsertTablo: %v", err) } dbFile, err := q.InsertTabloFile(ctx, sqlc.InsertTabloFileParams{ TabloID: tablo.ID, S3Key: "files/" + tablo.ID.String() + "/some-uuid", Filename: "report.pdf", ContentType: "application/pdf", SizeBytes: 1024, }) if err != nil { t.Fatalf("InsertTabloFile: %v", err) } sessionVal, _, err := store.Create(ctx, user.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} url := "/tablos/" + tablo.ID.String() + "/files/" + dbFile.ID.String() + "/download" req := httptest.NewRequest(http.MethodGet, url, nil) req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusFound { t.Fatalf("GET download status = %d; want 302\nbody: %s", rec.Code, rec.Body.String()) } loc := rec.Header().Get("Location") if loc != "https://example.com/signed?key=foo" { t.Errorf("Location = %q; want %q", loc, "https://example.com/signed?key=foo") } } // TestFileDownload_NonOwner verifies that a non-owner gets 404 on download (FILE-06). func TestFileDownload_NonOwner(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{} router := newFileTestRouter(q, store, stub) owner := preInsertUser(t, ctx, q, "dlowner@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: owner.ID, Title: "Owner Tablo", }) if err != nil { t.Fatalf("InsertTablo: %v", err) } dbFile, err := q.InsertTabloFile(ctx, sqlc.InsertTabloFileParams{ TabloID: tablo.ID, S3Key: "files/" + tablo.ID.String() + "/x", Filename: "secret.pdf", ContentType: "application/pdf", SizeBytes: 512, }) if err != nil { t.Fatalf("InsertTabloFile: %v", err) } nonOwner := preInsertUser(t, ctx, q, "dlnonowner@example.com", "correct-horse-12") sessionVal, _, err := store.Create(ctx, nonOwner.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} url := "/tablos/" + tablo.ID.String() + "/files/" + dbFile.ID.String() + "/download" req := httptest.NewRequest(http.MethodGet, url, nil) req.AddCookie(sessionCookie) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("non-owner download status = %d; want 404", rec.Code) } } // ---- TestFileDelete (FILE-05) ---- // TestFileDelete verifies that POST /tablos/{id}/files/{file_id}/delete // removes the DB row, calls S3 delete, and returns the FileRowGone fragment for HTMX (FILE-05). func TestFileDelete(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{} router := newFileTestRouter(q, store, stub) user := preInsertUser(t, ctx, q, "filedelete@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "Delete Test Tablo", }) if err != nil { t.Fatalf("InsertTablo: %v", err) } dbFile, err := q.InsertTabloFile(ctx, sqlc.InsertTabloFileParams{ TabloID: tablo.ID, S3Key: "files/" + tablo.ID.String() + "/del-uuid", Filename: "todelete.txt", ContentType: "text/plain", SizeBytes: 100, }) if err != nil { t.Fatalf("InsertTabloFile: %v", err) } sessionVal, _, err := store.Create(ctx, user.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} deleteURL := "/tablos/" + tablo.ID.String() + "/files/" + dbFile.ID.String() + "/delete" csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/files", []*http.Cookie{sessionCookie}) var body strings.Builder body.WriteString("gorilla.csrf.Token=" + csrfToken) req := httptest.NewRequest(http.MethodPost, deleteURL, strings.NewReader(body.String())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") req.AddCookie(sessionCookie) for _, c := range csrfCookies { req.AddCookie(c) } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("HTMX delete status = %d; want 200\nbody: %s", rec.Code, rec.Body.String()) } // Response should contain the gone zone for the file. respBody := rec.Body.String() if !strings.Contains(respBody, "file-"+dbFile.ID.String()) { t.Errorf("response missing file-row-zone id; body: %.300s", respBody) } // S3 delete should have been called. if stub.deletedKey != dbFile.S3Key { t.Errorf("stub.deletedKey = %q; want %q", stub.deletedKey, dbFile.S3Key) } // DB row should no longer exist. remaining, err := q.ListFilesByTablo(ctx, tablo.ID) if err != nil { t.Fatalf("ListFilesByTablo: %v", err) } if len(remaining) != 0 { t.Errorf("expected 0 files after delete, got %d", len(remaining)) } } // TestFileDelete_S3Failure verifies that when S3 delete fails, the DB row is still // deleted and the response is 200 (not 500) for the HTMX path (FILE-05 deviation pattern). func TestFileDelete_S3Failure(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{deleteErr: errors.New("s3 unavailable")} router := newFileTestRouter(q, store, stub) user := preInsertUser(t, ctx, q, "dels3fail@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, Title: "S3 Fail Delete Tablo", }) if err != nil { t.Fatalf("InsertTablo: %v", err) } dbFile, err := q.InsertTabloFile(ctx, sqlc.InsertTabloFileParams{ TabloID: tablo.ID, S3Key: "files/" + tablo.ID.String() + "/s3fail-uuid", Filename: "s3fail.txt", ContentType: "text/plain", SizeBytes: 50, }) if err != nil { t.Fatalf("InsertTabloFile: %v", err) } sessionVal, _, err := store.Create(ctx, user.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} deleteURL := "/tablos/" + tablo.ID.String() + "/files/" + dbFile.ID.String() + "/delete" csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/files", []*http.Cookie{sessionCookie}) var body strings.Builder body.WriteString("gorilla.csrf.Token=" + csrfToken) req := httptest.NewRequest(http.MethodPost, deleteURL, strings.NewReader(body.String())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") req.AddCookie(sessionCookie) for _, c := range csrfCookies { req.AddCookie(c) } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) // Even when S3 fails, HTMX path returns 200 (not 500). if rec.Code != http.StatusOK { t.Fatalf("S3 failure delete status = %d; want 200\nbody: %s", rec.Code, rec.Body.String()) } // DB row should still be deleted. remaining, err := q.ListFilesByTablo(ctx, tablo.ID) if err != nil { t.Fatalf("ListFilesByTablo: %v", err) } if len(remaining) != 0 { t.Errorf("expected 0 files after delete (S3 fail), got %d", len(remaining)) } } // ---- TestFileOwnership (FILE-06) ---- // TestFileOwnership verifies that a non-owner gets 404 on download, delete-confirm, // and delete routes (FILE-06). func TestFileOwnership(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() q := sqlc.New(pool) store := auth.NewStore(q) stub := &stubbedFileStorer{} router := newFileTestRouter(q, store, stub) owner := preInsertUser(t, ctx, q, "ownship@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: owner.ID, Title: "Ownership Tablo", }) if err != nil { t.Fatalf("InsertTablo: %v", err) } dbFile, err := q.InsertTabloFile(ctx, sqlc.InsertTabloFileParams{ TabloID: tablo.ID, S3Key: "files/" + tablo.ID.String() + "/own-uuid", Filename: "owned.txt", ContentType: "text/plain", SizeBytes: 10, }) if err != nil { t.Fatalf("InsertTabloFile: %v", err) } nonOwner := preInsertUser(t, ctx, q, "nonownship@example.com", "correct-horse-12") sessionVal, _, err := store.Create(ctx, nonOwner.ID) if err != nil { t.Fatalf("store.Create: %v", err) } sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} base := "/tablos/" + tablo.ID.String() + "/files/" + dbFile.ID.String() // Non-owner: GET download → 404 reqDL := httptest.NewRequest(http.MethodGet, base+"/download", nil) reqDL.AddCookie(sessionCookie) recDL := httptest.NewRecorder() router.ServeHTTP(recDL, reqDL) if recDL.Code != http.StatusNotFound { t.Errorf("non-owner download status = %d; want 404", recDL.Code) } // Non-owner: GET delete-confirm → 404 reqDC := httptest.NewRequest(http.MethodGet, base+"/delete-confirm", nil) reqDC.AddCookie(sessionCookie) recDC := httptest.NewRecorder() router.ServeHTTP(recDC, reqDC) if recDC.Code != http.StatusNotFound { t.Errorf("non-owner delete-confirm status = %d; want 404", recDC.Code) } // Non-owner: POST delete → 404 // We need a CSRF token from non-owner's session to get past CSRF middleware. csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/files", []*http.Cookie{sessionCookie}) var delBody strings.Builder delBody.WriteString("gorilla.csrf.Token=" + csrfToken) reqDel := httptest.NewRequest(http.MethodPost, base+"/delete", strings.NewReader(delBody.String())) reqDel.Header.Set("Content-Type", "application/x-www-form-urlencoded") reqDel.AddCookie(sessionCookie) for _, c := range csrfCookies { reqDel.AddCookie(c) } recDel := httptest.NewRecorder() router.ServeHTTP(recDel, reqDel) if recDel.Code != http.StatusNotFound { t.Errorf("non-owner delete status = %d; want 404", recDel.Code) } }