test: add comprehensive test suite (44 → 169 tests) and v1.1 plan
Add 125 new test functions across 10 new test files, covering: - CSRF middleware (8 tests): double-submit cookie validation - Auth middleware (12 tests): SessionLoader, RequireAdmin, context helpers - API handlers (28 tests): auth, faves CRUD, tags, users, export/import - Web handlers (41 tests): signup, login, password reset, fave CRUD, admin panel, feeds, import/export, profiles, settings - Config (8 tests): env parsing, defaults, trusted proxies, normalization - Database (6 tests): migrations, PRAGMAs, idempotency, seeding - Image processing (10 tests): JPEG/PNG, resize, EXIF strip, path traversal - Render (6 tests): page/error/partial rendering, template functions - Settings store (3 tests): CRUD operations - Regression tests for display name fallback and CSP-safe autocomplete Also adds CSRF middleware to testServer chain for end-to-end CSRF verification, TESTPLAN.md documenting coverage, and PLANS-v1.1.md with implementation plans for notes+OG, PWA, editing UX, and admin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9c3ca14578
commit
a8f3aa6f7e
12 changed files with 3820 additions and 2 deletions
316
PLANS-v1.1.md
Normal file
316
PLANS-v1.1.md
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
# Implementeringsplan v1.1 — Favoritter
|
||||||
|
|
||||||
|
Fire funksjoner som utvider Favoritter med notater og OG-metadata, PWA-installering med Android-delingsstøtte, bedre redigeringsflyt, og utvidede admin-verktøy.
|
||||||
|
|
||||||
|
## Oversikt
|
||||||
|
|
||||||
|
| # | Funksjon | Omfang | Avhengigheter |
|
||||||
|
|---|----------|--------|---------------|
|
||||||
|
| 1 | OG-støtte + fritekstfelt (notes) | Migrasjon, modell, store, alle handlere, maler, eksport/import, feed | Ingen |
|
||||||
|
| 2 | PWA + Android share intent | Service worker, manifest, nye handlere, base-mal, JS | Avhenger av 1 (deling pre-fyller notes) |
|
||||||
|
| 3 | Redigeringsforbedringer (UX) | Maler, CSS, ny toggle-rute, ny partial | Avhenger av 1 (notes vises i kort) |
|
||||||
|
| 4 | Admin-forbedringer | Store, handlere, maler, ruter | Ingen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funksjon 1: OG-støtte + fritekstfelt (notes)
|
||||||
|
|
||||||
|
### Beskrivelse
|
||||||
|
|
||||||
|
Legger til et valgfritt langtekstfelt «notes» på hver favoritt. Forbedrer Open Graph-metatagger på detaljsiden slik at `og:description` bruker notes når det er tilgjengelig.
|
||||||
|
|
||||||
|
### Berørte filer
|
||||||
|
|
||||||
|
| Fil | Endring |
|
||||||
|
|-----|---------|
|
||||||
|
| `internal/database/migrations/002_add_fave_notes.sql` | Ny fil: ALTER TABLE faves ADD COLUMN notes |
|
||||||
|
| `internal/model/fave.go` | Legg til `Notes string` i Fave-struct |
|
||||||
|
| `internal/store/fave.go` | Oppdater alle SQL-spørringer, Create/Update-signaturer, scanFaves |
|
||||||
|
| `internal/handler/fave.go` | Les notes fra skjema i create/edit/update |
|
||||||
|
| `internal/handler/api/api.go` | Legg til notes i request/response-structs og faveJSON |
|
||||||
|
| `internal/handler/import_export.go` | Legg til notes i ExportFave, CSV-kolonner, import |
|
||||||
|
| `internal/handler/feed.go` | Bruk notes som feed item description |
|
||||||
|
| `web/templates/pages/fave_form.html` | Legg til textarea for notes mellom URL og bilde |
|
||||||
|
| `web/templates/pages/fave_detail.html` | Vis notes, forbedre og:description |
|
||||||
|
|
||||||
|
### Migrasjons-SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 002_add_fave_notes.sql
|
||||||
|
-- Legger til et valgfritt notatfelt på favoritter.
|
||||||
|
ALTER TABLE faves ADD COLUMN notes TEXT NOT NULL DEFAULT '';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementeringssteg
|
||||||
|
|
||||||
|
1. **Migrasjon**: Opprett `internal/database/migrations/002_add_fave_notes.sql`
|
||||||
|
2. **Modell**: Legg til `Notes string` etter `ImagePath` i `model.Fave`
|
||||||
|
3. **Store — Create**: Endre signatur til `Create(userID, description, url, imagePath, notes, privacy)`, oppdater INSERT
|
||||||
|
4. **Store — GetByID**: Legg til `f.notes` i SELECT og `&f.Notes` i Scan
|
||||||
|
5. **Store — Update**: Endre signatur, legg til `notes = ?` i UPDATE
|
||||||
|
6. **Store — Alle list-metoder**: Legg til `f.notes` i SELECT for `ListByUser`, `ListPublicByUser`, `ListPublic`, `ListByTag`
|
||||||
|
7. **Store — scanFaves**: Legg til `&f.Notes` i Scan
|
||||||
|
8. **Handler — fave.go**: Les `notes` fra form i `handleFaveCreate` og `handleFaveUpdate`. Pass `fave.Notes` i `handleFaveEdit` template data
|
||||||
|
9. **Handler — api/api.go**: Legg til `Notes` i request/response-structs, `faveJSON`, import
|
||||||
|
10. **Handler — import_export.go**: Legg til notes i `ExportFave`, CSV header/rader, import-parsing
|
||||||
|
11. **Handler — feed.go**: Sett `item.Description = f.Notes` når det ikke er tomt i `favesToFeedItems`
|
||||||
|
12. **Template — fave_form.html**: Legg til `<textarea id="notes" name="notes">` mellom URL og bilde
|
||||||
|
13. **Template — fave_detail.html**: Vis `{{if .Notes}}<div class="fave-notes">{{.Notes}}</div>{{end}}`, forbedre `og:description`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{if .Notes}}
|
||||||
|
<meta property="og:description" content="{{truncate 200 .Notes}}">
|
||||||
|
{{else if .URL}}
|
||||||
|
<meta property="og:description" content="{{.URL}}">
|
||||||
|
{{else}}
|
||||||
|
<meta property="og:description" content="En favoritt av {{.DisplayName}} på {{$.SiteName}}">
|
||||||
|
{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tester
|
||||||
|
|
||||||
|
- Oppdater `TestFaveCRUD` (Create/Update-kall med notes)
|
||||||
|
- Ny `TestFaveNotes` (CRUD med notes-felt)
|
||||||
|
- Oppdater API CRUD-tester, ny `TestAPICreateFaveWithNotes`
|
||||||
|
- ~5 nye, ~7 endrede
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funksjon 2: PWA + Android share intent
|
||||||
|
|
||||||
|
### Beskrivelse
|
||||||
|
|
||||||
|
Gjør Favoritter installerbar som Progressive Web App med offline-støtte for statiske ressurser. Web Share Target API lar Android-brukere dele lenker direkte til Favoritter fra hvilken som helst app.
|
||||||
|
|
||||||
|
### Berørte filer
|
||||||
|
|
||||||
|
| Fil | Endring |
|
||||||
|
|-----|---------|
|
||||||
|
| `web/static/sw.js` | Ny fil: Service worker (cache-first statisk, network-first HTML) |
|
||||||
|
| `web/static/icons/icon-192.png` | Ny fil: PWA-ikon 192x192 |
|
||||||
|
| `web/static/icons/icon-512.png` | Ny fil: PWA-ikon 512x512 |
|
||||||
|
| `web/static/icons/icon-512-maskable.png` | Ny fil: Maskable ikon for Android |
|
||||||
|
| `internal/handler/pwa.go` | Ny fil: 3 handlere (manifest, SW, share) |
|
||||||
|
| `internal/handler/handler.go` | 3 nye ruter |
|
||||||
|
| `internal/handler/fave.go` | `handleFaveNew` leser url/description/notes fra query-parametre |
|
||||||
|
| `web/templates/layouts/base.html` | Manifest-link, theme-color, base-path meta |
|
||||||
|
| `web/static/js/app.js` | SW-registrering |
|
||||||
|
|
||||||
|
### Implementeringssteg
|
||||||
|
|
||||||
|
1. **Ikoner**: Opprett `web/static/icons/` med 192px, 512px, 512px-maskable PNG-er
|
||||||
|
2. **Service worker** (`web/static/sw.js`): Bruk `{{BASE_PATH}}`-plassholder, cache-first for `/static/`, network-first for HTML
|
||||||
|
3. **pwa.go — handleManifest**: Dynamisk JSON med BasePath injisert i `start_url`, `scope`, ikonruter og `share_target.action`. Share target bruker **GET** for å unngå CSRF-problemer
|
||||||
|
4. **pwa.go — handleServiceWorker**: Les sw.js fra embedded FS, erstatt `{{BASE_PATH}}`, sett `Cache-Control: no-cache`
|
||||||
|
5. **pwa.go — handleShare**: GET-handler bak `requireLogin`. Les `url`, `text`, `title` fra query. Mange Android-apper sender URL i `text`-feltet — sjekk begge. Redirect til `/faves/new?url=...&description=...¬es=...`
|
||||||
|
6. **Ruter**: `GET /manifest.json` (offentlig), `GET /sw.js` (offentlig), `GET /share` (krever login)
|
||||||
|
7. **handleFaveNew**: Les `url`, `description`, `notes` fra query-parametre for pre-fill
|
||||||
|
8. **base.html**: `<meta name="theme-color" content="#1095c1">`, `<link rel="manifest">`, `<meta name="base-path">`
|
||||||
|
9. **app.js**: SW-registrering via `navigator.serviceWorker.register()`, les base-path fra meta-tag
|
||||||
|
|
||||||
|
### Designbeslutninger
|
||||||
|
|
||||||
|
- **GET for share target**: Android kan ikke levere CSRF-tokens, så POST er utelukket. GET-handler redirecter til fave-skjema
|
||||||
|
- **Dynamisk manifest**: Nødvendig for å injisere BasePath korrekt ved subpath-deployment
|
||||||
|
- **SW Cache-Control: no-cache**: Nettleseren må kunne sjekke for oppdateringer
|
||||||
|
|
||||||
|
### Tester
|
||||||
|
|
||||||
|
- `TestManifestJSON`, `TestServiceWorkerContent`, `TestServiceWorkerBasePath`
|
||||||
|
- `TestShareRedirectsToFaveNew`, `TestShareTextFieldFallback`, `TestShareRequiresLogin`
|
||||||
|
- ~6 nye
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funksjon 3: Redigeringsforbedringer (UX)
|
||||||
|
|
||||||
|
### Beskrivelse
|
||||||
|
|
||||||
|
Backend-redigering fungerer allerede (rediger, slett, personvernveksling). Problemet er at handlingene kun er synlige på detaljsiden. Denne funksjonen gjør dem tilgjengelige fra listevisninger.
|
||||||
|
|
||||||
|
### Tier 1 — Synlige handlinger i listevisninger
|
||||||
|
|
||||||
|
| Fil | Endring |
|
||||||
|
|-----|---------|
|
||||||
|
| `web/templates/pages/fave_list.html` | Legg til rediger/slett-knapper i hver card footer |
|
||||||
|
| `web/templates/pages/profile.html` | Legg til rediger-lenke for eiers egne kort |
|
||||||
|
| `web/static/css/style.css` | Kompakte `.fave-card-actions`-stiler |
|
||||||
|
|
||||||
|
**fave_list.html**: Legg til etter tags-footer, før `</article>`:
|
||||||
|
```html
|
||||||
|
<footer class="fave-card-actions">
|
||||||
|
<a href="{{basePath}}/faves/{{.ID}}/edit" class="fave-action-link">Rediger</a>
|
||||||
|
<button hx-delete="{{basePath}}/faves/{{.ID}}"
|
||||||
|
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
|
||||||
|
hx-target="closest article" hx-swap="outerHTML"
|
||||||
|
class="fave-action-btn secondary">Slett</button>
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Eksisterende `handleFaveDelete` returnerer allerede tom response for HTMX-forespørsler, så `hx-target="closest article" hx-swap="outerHTML"` fjerner kortet fra DOM.
|
||||||
|
|
||||||
|
**profile.html**: Vis kun for eier med `{{if $d.IsOwner}}`.
|
||||||
|
|
||||||
|
### Tier 2 — Rask personvernveksler
|
||||||
|
|
||||||
|
| Fil | Endring |
|
||||||
|
|-----|---------|
|
||||||
|
| `internal/store/fave.go` | Ny `UpdatePrivacy(id, privacy)` metode |
|
||||||
|
| `internal/handler/fave.go` | Ny `handleFaveTogglePrivacy` handler |
|
||||||
|
| `internal/handler/handler.go` | Ny rute: `POST /faves/{id}/privacy` |
|
||||||
|
| `web/templates/partials/privacy_toggle.html` | Ny HTMX-partial for toggle-knapp |
|
||||||
|
| `web/templates/pages/fave_list.html` | Bruk toggle i stedet for statisk badge |
|
||||||
|
| `web/templates/pages/fave_detail.html` | Bruk toggle for eier |
|
||||||
|
|
||||||
|
**Store — UpdatePrivacy**:
|
||||||
|
```go
|
||||||
|
func (s *FaveStore) UpdatePrivacy(id int64, privacy string) error {
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`UPDATE faves SET privacy = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
WHERE id = ?`, privacy, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handler**: Verifiserer eierskap, veksler mellom "public"/"private", returnerer oppdatert partial.
|
||||||
|
|
||||||
|
**Partial** (`privacy_toggle.html`):
|
||||||
|
```html
|
||||||
|
<span class="privacy-toggle" id="privacy-{{.ID}}">
|
||||||
|
<button hx-post="{{basePath}}/faves/{{.ID}}/privacy"
|
||||||
|
hx-target="#privacy-{{.ID}}" hx-swap="outerHTML"
|
||||||
|
class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}">
|
||||||
|
{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
CSRF-token håndteres automatisk av `htmx:configRequest`-hooken i app.js.
|
||||||
|
|
||||||
|
### Tester
|
||||||
|
|
||||||
|
- `TestUpdatePrivacy`, `TestTogglePrivacyOwner`, `TestTogglePrivacyNotOwner`
|
||||||
|
- `TestFaveListShowsEditButton`, `TestFaveListDeleteViaHTMX`
|
||||||
|
- ~5 nye
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Funksjon 4: Admin-forbedringer
|
||||||
|
|
||||||
|
### Beskrivelse
|
||||||
|
|
||||||
|
Utvider admin-panelet med mulighet til å endre brukerroller og permanent slette brukerkontoer. Eksisterende admin-funksjonalitet som allerede fungerer:
|
||||||
|
|
||||||
|
- ✅ Lukke registreringer (`/admin/settings` → signup_mode)
|
||||||
|
- ✅ Låse/deaktivere brukere (`/admin/users/{id}/toggle-disabled`)
|
||||||
|
- ✅ Tilbakestille passord (`/admin/users/{id}/reset-password`)
|
||||||
|
- ✅ Godkjenne/avvise registreringsforespørsler
|
||||||
|
- ✅ Opprette brukere
|
||||||
|
|
||||||
|
### Del A: Endre brukerrolle
|
||||||
|
|
||||||
|
| Fil | Endring |
|
||||||
|
|-----|---------|
|
||||||
|
| `internal/store/user.go` | Ny `SetRole(userID, role)` metode |
|
||||||
|
| `internal/handler/admin.go` | Ny `handleAdminSetRole` handler |
|
||||||
|
| `internal/handler/handler.go` | Ny rute: `POST /admin/users/{id}/role` |
|
||||||
|
| `web/templates/pages/admin_users.html` | Rolle-dropdown per brukerrad |
|
||||||
|
|
||||||
|
**Store — SetRole**: Validerer at role er "user" eller "admin", oppdaterer databasen.
|
||||||
|
|
||||||
|
**Handler**: Forhindrer at admin endrer egen rolle. Oppdaterer rolle og viser bekreftelse.
|
||||||
|
|
||||||
|
**Template**: `<select name="role">` med `onchange="this.form.submit()"` i handlingskolonnen.
|
||||||
|
|
||||||
|
**NB**: `onchange` inline handler blokkeres av CSP (`script-src 'self'`). Bruk i stedet en delegert event listener i app.js, eller legg til en submit-knapp:
|
||||||
|
```html
|
||||||
|
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/role" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
|
<select name="role" class="inline-input">
|
||||||
|
<option value="user" {{if eq .Role "user"}}selected{{end}}>Bruker</option>
|
||||||
|
<option value="admin" {{if eq .Role "admin"}}selected{{end}}>Admin</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="nav-button outline">Lagre</button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Del B: Slette brukerkontoer
|
||||||
|
|
||||||
|
| Fil | Endring |
|
||||||
|
|-----|---------|
|
||||||
|
| `internal/store/user.go` | Ny `Delete(userID)` metode |
|
||||||
|
| `internal/handler/admin.go` | Ny `handleAdminDeleteUser` handler |
|
||||||
|
| `internal/handler/handler.go` | Ny rute: `POST /admin/users/{id}/delete` |
|
||||||
|
| `web/templates/pages/admin_users.html` | Slett-knapp med `hx-confirm` |
|
||||||
|
|
||||||
|
**Store — Delete**: `DELETE FROM users WHERE id = ?`. Kaskadesletting via foreign keys fjerner sessions, faves og fave_tags automatisk.
|
||||||
|
|
||||||
|
**Handler**: Forhindrer selvsletting. Sletter brukerens bilder fra disk **før** databasesletting (fordi kaskaden fjerner fave-radene som refererer til bildefilene). Viser bekreftelse.
|
||||||
|
|
||||||
|
**Template**: HTMX-knapp med `hx-confirm`:
|
||||||
|
```html
|
||||||
|
<button hx-post="{{basePath}}/admin/users/{{.ID}}/delete"
|
||||||
|
hx-confirm="Er du HELT sikker? Dette sletter brukeren og alle favorittene permanent."
|
||||||
|
hx-target="closest tr" hx-swap="outerHTML"
|
||||||
|
class="nav-button outline" style="color: var(--pico-del-color);">Slett</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tester
|
||||||
|
|
||||||
|
- `TestSetRole`, `TestSetRoleInvalid`, `TestDeleteUser`, `TestDeleteUserCascade`
|
||||||
|
- `TestAdminSetRoleSuccess`, `TestAdminSetRoleSelf`
|
||||||
|
- `TestAdminDeleteUserSuccess`, `TestAdminDeleteUserSelf`, `TestAdminDeleteUserCascadesData`
|
||||||
|
- ~9 nye
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Samlet implementeringsrekkefølge
|
||||||
|
|
||||||
|
```
|
||||||
|
Fase 1: Funksjon 1 + Funksjon 4 (parallelt, ingen avhengigheter)
|
||||||
|
├── 1a: Migrasjon + modell + store (notes)
|
||||||
|
├── 1b: Handlere og maler (notes + OG)
|
||||||
|
├── 4a: SetRole + handler + mal
|
||||||
|
└── 4b: Delete + handler + mal
|
||||||
|
|
||||||
|
Fase 2: Funksjon 3 (krever notes fra Fase 1)
|
||||||
|
├── 3a: Tier 1 — rediger/slett-knapper i listevisninger
|
||||||
|
└── 3b: Tier 2 — personvernveksler
|
||||||
|
|
||||||
|
Fase 3: Funksjon 2 (krever notes fra Fase 1)
|
||||||
|
├── 2a: Ikoner + service worker + manifest-handler
|
||||||
|
├── 2b: Share-handler + pre-fill
|
||||||
|
└── 2c: Base-mal + SW-registrering
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nye endepunkter
|
||||||
|
|
||||||
|
| Metode | Rute | Handler | Auth |
|
||||||
|
|--------|------|---------|------|
|
||||||
|
| GET | `/manifest.json` | handleManifest | Nei |
|
||||||
|
| GET | `/sw.js` | handleServiceWorker | Nei |
|
||||||
|
| GET | `/share` | handleShare | Ja |
|
||||||
|
| POST | `/faves/{id}/privacy` | handleFaveTogglePrivacy | Ja |
|
||||||
|
| POST | `/admin/users/{id}/role` | handleAdminSetRole | Admin |
|
||||||
|
| POST | `/admin/users/{id}/delete` | handleAdminDeleteUser | Admin |
|
||||||
|
|
||||||
|
### Nye filer
|
||||||
|
|
||||||
|
| Fil | Beskrivelse |
|
||||||
|
|-----|-------------|
|
||||||
|
| `internal/database/migrations/002_add_fave_notes.sql` | Migrasjon: notes-kolonne |
|
||||||
|
| `internal/handler/pwa.go` | PWA-handlere (manifest, SW, share) |
|
||||||
|
| `web/static/sw.js` | Service worker |
|
||||||
|
| `web/static/icons/icon-*.png` | PWA-ikoner (3 stk) |
|
||||||
|
| `web/templates/partials/privacy_toggle.html` | HTMX personvernveksler |
|
||||||
|
|
||||||
|
### Estimert testtillegg
|
||||||
|
|
||||||
|
| Funksjon | Nye tester |
|
||||||
|
|----------|------------|
|
||||||
|
| 1: Notes + OG | ~5 |
|
||||||
|
| 2: PWA + share | ~6 |
|
||||||
|
| 3: Redigering UX | ~5 |
|
||||||
|
| 4: Admin | ~9 |
|
||||||
|
| **Totalt** | **~25** |
|
||||||
388
TESTPLAN.md
Normal file
388
TESTPLAN.md
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
# Testplan — Favoritter
|
||||||
|
|
||||||
|
## Dekning (oppdatert 2026-04-03)
|
||||||
|
|
||||||
|
| Lag | Pakke | Testfiler | Testfunksjoner | Dekning |
|
||||||
|
|-----|-------|-----------|----------------|---------|
|
||||||
|
| Data | `internal/store` | 6 | 24 | 70.8 % |
|
||||||
|
| HTTP (web) | `internal/handler` | 2 | 55 | 56.1 % |
|
||||||
|
| HTTP (API) | `internal/handler/api` | 1 | 28 | 75.8 % |
|
||||||
|
| Mellomvare | `internal/middleware` | 3 | 29 | 83.1 % |
|
||||||
|
| Konfig | `internal/config` | 1 | 8 | 81.1 % |
|
||||||
|
| Bilde | `internal/image` | 1 | 10 | 85.7 % |
|
||||||
|
| Database | `internal/database` | 1 | 6 | 66.0 % |
|
||||||
|
| Rendering | `internal/render` | 1 | 6 | 72.7 % |
|
||||||
|
| Modell | `internal/model` | 0 | 0 | 0.0 % (kun datatyper) |
|
||||||
|
|
||||||
|
**Totalt**: 16 testfiler, 167 testfunksjoner, alle grønne.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioriteter
|
||||||
|
|
||||||
|
- **P0 — Kritisk**: Sikkerhet, autentisering, autorisasjon
|
||||||
|
- **P1 — Høy**: Forretningslogikk, dataintegritet, API-kontrakter
|
||||||
|
- **P2 — Middels**: Konfigurasjon, feilhåndtering, kanttilfeller
|
||||||
|
- **P3 — Lav**: Rendering, logging, hjelpefunksjoner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Sikkerhet og tilgangskontroll
|
||||||
|
|
||||||
|
### CSRF-beskyttelse (`internal/middleware/csrf.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestCSRFTokenSetInCookie` | POST uten eksisterende cookie → ny `csrf_token`-cookie settes |
|
||||||
|
| 2 | `TestCSRFValidTokenAccepted` | POST med matchende cookie + form-felt → 200 |
|
||||||
|
| 3 | `TestCSRFMismatchRejected` | POST med feil token i form-felt → 403 |
|
||||||
|
| 4 | `TestCSRFMissingTokenRejected` | POST uten token i form-felt/header → 403 |
|
||||||
|
| 5 | `TestCSRFHeaderFallback` | POST med token i `X-CSRF-Token`-header → 200 |
|
||||||
|
| 6 | `TestCSRFSkippedForAPI` | POST til `/api/`-ruter → CSRF sjekkes ikke |
|
||||||
|
| 7 | `TestCSRFSafeMethodsPassThrough` | GET/HEAD/OPTIONS → ingen validering |
|
||||||
|
|
||||||
|
### Auth-mellomvare (`internal/middleware/auth.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestSessionLoaderValidToken` | Gyldig session-cookie → bruker satt i context |
|
||||||
|
| 2 | `TestSessionLoaderInvalidToken` | Ugyldig cookie → context uten bruker, ingen feil |
|
||||||
|
| 3 | `TestSessionLoaderNoCookie` | Ingen cookie → context uten bruker |
|
||||||
|
| 4 | `TestRequireLoginRedirectsToLogin` | Uautentisert → 302 til `/login` |
|
||||||
|
| 5 | `TestRequireLoginAllowsAuthenticated` | Autentisert → neste handler kjøres |
|
||||||
|
| 6 | `TestRequireAdminRejectsNonAdmin` | Vanlig bruker → 403 |
|
||||||
|
| 7 | `TestRequireAdminAllowsAdmin` | Admin-bruker → neste handler kjøres |
|
||||||
|
|
||||||
|
### API-autentisering (`internal/handler/api/`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestAPILoginSuccess` | Korrekt brukernavn/passord → token + brukerinfo |
|
||||||
|
| 2 | `TestAPILoginWrongPassword` | Feil passord → 401 |
|
||||||
|
| 3 | `TestAPILoginInvalidBody` | Ugyldig JSON → 400 |
|
||||||
|
| 4 | `TestAPILogout` | Gyldig session → session slettet, cookie fjernet |
|
||||||
|
| 5 | `TestAPIRequiresAuth` | Beskyttede endepunkter uten session → 401/redirect |
|
||||||
|
|
||||||
|
### Autorisasjon (eierskap)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestCannotEditOtherUsersFave` | PUT `/api/v1/faves/{id}` med annen bruker → 403 |
|
||||||
|
| 2 | `TestCannotDeleteOtherUsersFave` | DELETE `/api/v1/faves/{id}` med annen bruker → 403 |
|
||||||
|
| 3 | `TestPrivateFaveHiddenFromAPI` | GET `/api/v1/faves/{id}` privat fave, annen bruker → 404 |
|
||||||
|
| 4 | `TestPrivateFaveVisibleToOwnerAPI` | GET `/api/v1/faves/{id}` privat fave, eier → 200 |
|
||||||
|
| 5 | `TestAdminEndpointsRequireAdminRole` | Ikke-admin → 403 på alle admin-ruter |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 — Forretningslogikk og API-kontrakter
|
||||||
|
|
||||||
|
### Store: Innstillinger (`internal/store/settings.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestSettingsGetDefault` | Hent standardinnstillinger etter migrasjon |
|
||||||
|
| 2 | `TestSettingsUpdate` | Oppdater site_name, description, signup_mode → endringer leses tilbake |
|
||||||
|
| 3 | `TestSettingsUpdatePartial` | Oppdater kun ett felt → andre felter beholdes |
|
||||||
|
|
||||||
|
### API: Favoritter CRUD (`internal/handler/api/`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestAPICreateFave` | POST med beskrivelse, URL, privacy, tags → 201 + komplett JSON |
|
||||||
|
| 2 | `TestAPICreateFaveMinimal` | POST med kun beskrivelse → 201, standardverdier |
|
||||||
|
| 3 | `TestAPICreateFaveMissingDescription` | POST uten beskrivelse → 400 |
|
||||||
|
| 4 | `TestAPIGetFave` | GET `/api/v1/faves/{id}` → komplett JSON med tagger |
|
||||||
|
| 5 | `TestAPIGetFaveNotFound` | GET med ugyldig ID → 404 |
|
||||||
|
| 6 | `TestAPIUpdateFave` | PUT med endringer → oppdatert JSON |
|
||||||
|
| 7 | `TestAPIUpdateFavePartial` | PUT med kun tags → andre felter uendret |
|
||||||
|
| 8 | `TestAPIDeleteFave` | DELETE → 204 No Content |
|
||||||
|
| 9 | `TestAPIDeleteFaveNotFound` | DELETE med ugyldig ID → 404 |
|
||||||
|
| 10 | `TestAPIListFaves` | GET `/api/v1/faves` → paginert resultat med total |
|
||||||
|
| 11 | `TestAPIListFavesPagination` | Sjekk page/limit-parametre og grense (limit ≤ 100) |
|
||||||
|
|
||||||
|
### API: Brukere og offentlige favoritter
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestAPIGetUser` | GET `/api/v1/users/{username}` → brukerinfo |
|
||||||
|
| 2 | `TestAPIGetUserNotFound` | Ukjent brukernavn → 404 |
|
||||||
|
| 3 | `TestAPIGetDisabledUser` | Deaktivert bruker → 404 |
|
||||||
|
| 4 | `TestAPIGetUserFaves` | GET `/api/v1/users/{username}/faves` → kun offentlige |
|
||||||
|
| 5 | `TestAPIGetUserFavesEmpty` | Bruker uten favoritter → tom liste |
|
||||||
|
|
||||||
|
### API: Tagger
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestAPISearchTags` | GET `/api/v1/tags?q=prefix` → matchende tagger |
|
||||||
|
| 2 | `TestAPISearchTagsEmpty` | Søk uten treff → tom liste |
|
||||||
|
| 3 | `TestAPISearchTagsLimit` | `limit`-parameter respekteres, maks 100 |
|
||||||
|
|
||||||
|
### API: Eksport/import
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestAPIExport` | GET `/api/v1/export/json` → alle brukerens favoritter |
|
||||||
|
| 2 | `TestAPIImportValid` | POST med JSON-array → imported/total-svar |
|
||||||
|
| 3 | `TestAPIImportSkipsEmpty` | Oppføringer uten beskrivelse → hoppes over |
|
||||||
|
| 4 | `TestAPIImportInvalidJSON` | Ugyldig JSON → 400 |
|
||||||
|
| 5 | `TestAPIImportBodyLimit` | Stor body → avvist (MaxUploadSize) |
|
||||||
|
|
||||||
|
### Handler: Registrering og pålogging (`internal/handler/auth.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestSignupPageRendering` | GET `/signup` → skjema med CSRF-token |
|
||||||
|
| 2 | `TestSignupSuccess` | POST med gyldig data → registreringsforespørsel opprettet |
|
||||||
|
| 3 | `TestSignupDuplicate` | Duplisert brukernavn → feilmelding |
|
||||||
|
| 4 | `TestSignupDisabled` | `signup_mode=closed` → 403 eller redirect |
|
||||||
|
| 5 | `TestPasswordResetFlow` | Bruker med must_reset → tvinges til å endre passord |
|
||||||
|
| 6 | `TestPasswordChangeSuccess` | POST med korrekt gammelt + nytt passord → oppdatert |
|
||||||
|
| 7 | `TestPasswordChangeMismatch` | Nytt passord ≠ bekreftelse → feilmelding |
|
||||||
|
| 8 | `TestLogout` | POST `/logout` → session slettet, redirect til login |
|
||||||
|
|
||||||
|
### Handler: Favoritter CRUD (web) (`internal/handler/fave.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestCreateFavePage` | GET `/faves/new` → skjema rendres |
|
||||||
|
| 2 | `TestCreateFaveSubmit` | POST med gyldig data → redirect til favoritt |
|
||||||
|
| 3 | `TestCreateFaveWithImage` | POST med multipart/bilde → bilde lagret |
|
||||||
|
| 4 | `TestEditFavePage` | GET `/faves/{id}/edit` → skjema med eksisterende data |
|
||||||
|
| 5 | `TestEditFaveSubmit` | POST med endringer → oppdatert |
|
||||||
|
| 6 | `TestEditFaveNotOwner` | Annen bruker → 403 |
|
||||||
|
| 7 | `TestDeleteFave` | POST `/faves/{id}/delete` → slettet, redirect |
|
||||||
|
| 8 | `TestDeleteFaveNotOwner` | Annen bruker → 403 |
|
||||||
|
| 9 | `TestFaveListPagination` | Navigasjon med page-parameter |
|
||||||
|
|
||||||
|
### Handler: Administrasjon (`internal/handler/admin.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestAdminDashboard` | GET `/admin` → statistikk rendres |
|
||||||
|
| 2 | `TestAdminUserList` | GET `/admin/users` → alle brukere vises |
|
||||||
|
| 3 | `TestAdminDisableUser` | POST → bruker deaktiveres |
|
||||||
|
| 4 | `TestAdminEnableUser` | POST → bruker reaktiveres |
|
||||||
|
| 5 | `TestAdminApproveSignup` | POST → bruker opprettes, forespørsel fjernes |
|
||||||
|
| 6 | `TestAdminRejectSignup` | POST → forespørsel fjernes, bruker opprettes ikke |
|
||||||
|
| 7 | `TestAdminUpdateSettings` | POST → innstillinger oppdateres |
|
||||||
|
|
||||||
|
### Handler: Profil (`internal/handler/profile.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestPublicProfileVisible` | Offentlig profil → bio, visningsnavn vist |
|
||||||
|
| 2 | `TestLimitedProfileHidesBio` | Begrenset profil → bio skjult, merknad vist |
|
||||||
|
| 3 | `TestProfileEditPage` | GET `/profile/edit` → skjema med gjeldende verdier |
|
||||||
|
| 4 | `TestProfileUpdateDisplayName` | POST → visningsnavn oppdatert |
|
||||||
|
| 5 | `TestProfileUpdateVisibility` | POST → synlighet endret |
|
||||||
|
| 6 | `TestProfileAvatarUpload` | POST med bilde → avatar lagret |
|
||||||
|
|
||||||
|
### Handler: Feed (`internal/handler/feed.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestGlobalFeedAtom` | GET `/feed` → gyldig Atom XML |
|
||||||
|
| 2 | `TestUserFeedAtom` | GET `/{username}/feed` → kun brukerens offentlige |
|
||||||
|
| 3 | `TestFeedExcludesPrivate` | Private favoritter utelates |
|
||||||
|
| 4 | `TestFeedLinksUseExternalURL` | Lenker bruker `EXTERNAL_URL`, ikke localhost |
|
||||||
|
|
||||||
|
### Handler: Import/Eksport (web) (`internal/handler/import_export.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestExportPageRendering` | GET `/export` → eksportvalg vist |
|
||||||
|
| 2 | `TestExportJSON` | GET `/export/json` → JSON-array med alle faves |
|
||||||
|
| 3 | `TestExportCSV` | GET `/export/csv` → gyldig CSV med header |
|
||||||
|
| 4 | `TestImportJSON` | POST med JSON-fil → favoritter importert |
|
||||||
|
| 5 | `TestImportCSV` | POST med CSV-fil → favoritter importert |
|
||||||
|
| 6 | `TestImportDuplicateHandling` | Duplikater → håndteres uten feil |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 — Konfigurasjon, bilde, database
|
||||||
|
|
||||||
|
### Konfigurasjon (`internal/config/config.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestLoadDefaults` | Ingen env-variabler → fornuftige standardverdier |
|
||||||
|
| 2 | `TestLoadFromEnv` | Alle env-variabler satt → config inneholder riktige verdier |
|
||||||
|
| 3 | `TestTrustedProxiesParsing` | `TRUSTED_PROXIES=10.0.0.0/8,192.168.0.0/16` → korrekte IPNet |
|
||||||
|
| 4 | `TestTrustedProxiesInvalid` | Ugyldig CIDR → logges, hoppes over |
|
||||||
|
| 5 | `TestBasePathNormalization` | Trailing slash fjernes, tom streng beholdes |
|
||||||
|
| 6 | `TestSessionLifetimeParsing` | `SESSION_LIFETIME=48h` → korrekt Duration |
|
||||||
|
| 7 | `TestDevModeFlag` | `FAVORITTER_DEV_MODE=true` → DevMode = true |
|
||||||
|
| 8 | `TestMaxUploadSizeParsing` | `MAX_UPLOAD_SIZE=10485760` → korrekt int64 |
|
||||||
|
|
||||||
|
### Bildebehandling (`internal/image/image.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestProcessJPEG` | Gyldig JPEG → UUID-filnavn, lagret i uploadDir |
|
||||||
|
| 2 | `TestProcessPNG` | Gyldig PNG → re-encoded, EXIF strippet |
|
||||||
|
| 3 | `TestProcessResizeWideImage` | Bilde > 1920px bredt → nedskalert til MaxWidth |
|
||||||
|
| 4 | `TestProcessSmallImageNotResized` | Bilde < 1920px → beholdes som det er |
|
||||||
|
| 5 | `TestProcessInvalidMIME` | `text/plain`-fil → feil returneres |
|
||||||
|
| 6 | `TestProcessCorruptImage` | Korrupt bildedata → feil returneres |
|
||||||
|
| 7 | `TestProcessUUIDFilename` | Filnavn bruker UUID, aldri brukerens opprinnelige filnavn |
|
||||||
|
| 8 | `TestAllowedTypes` | Sjekk at JPEG, PNG, GIF, WebP er tillatt |
|
||||||
|
|
||||||
|
### Databasemigrasjoner (`internal/database/database.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestOpenInMemory` | `:memory:` → fungerende tilkobling |
|
||||||
|
| 2 | `TestMigrateCreatesAllTables` | Migrasjon → alle forventede tabeller finnes |
|
||||||
|
| 3 | `TestMigrateIdempotent` | Kjør Migrate to ganger → ingen feil |
|
||||||
|
| 4 | `TestPRAGMAs` | WAL-modus, foreign_keys, journal_size_limit satt |
|
||||||
|
| 5 | `TestSingleConnection` | MaxOpenConns = 1 verifisert |
|
||||||
|
|
||||||
|
### Mellomvare: Context-hjelpere (`internal/middleware/context.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestUserFromContextPresent` | Bruker i context → returneres |
|
||||||
|
| 2 | `TestUserFromContextAbsent` | Ingen bruker → nil |
|
||||||
|
| 3 | `TestCSRFTokenFromContext` | Token i context → returneres |
|
||||||
|
| 4 | `TestFlashFromContext` | Flash-melding i context → returneres |
|
||||||
|
|
||||||
|
### Mellomvare: Logger (`internal/middleware/logger.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestLoggerRecordsStatusCode` | Response status kodes logges |
|
||||||
|
| 2 | `TestLoggerRecordsDuration` | Forespørselstid logges |
|
||||||
|
| 3 | `TestLoggerIncludesPath` | URL-path inkluderes i loggoppføring |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3 — Rendering og hjelpefunksjoner
|
||||||
|
|
||||||
|
### Template-rendering (`internal/render/render.go`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestRenderPage` | Rendrer side med layout → komplett HTML |
|
||||||
|
| 2 | `TestRenderPageWithData` | PageData (Title, brukerinfo) → interpolert i template |
|
||||||
|
| 3 | `TestRenderPartial` | Rendrer partial uten layout |
|
||||||
|
| 4 | `TestRenderMissingTemplate` | Ukjent template-navn → feilhåndtering |
|
||||||
|
| 5 | `TestCSRFTokenInTemplate` | CSRF-token tilgjengelig i template-context |
|
||||||
|
|
||||||
|
### API JSON-hjelpere (`internal/handler/api/`)
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestJsonOKFormat` | Content-Type: application/json, gyldig JSON |
|
||||||
|
| 2 | `TestJsonErrorFormat` | Feil → `{"error": "melding"}` + riktig statuskode |
|
||||||
|
| 3 | `TestUserJSONPublicProfile` | Offentlig profil → bio og avatar inkludert |
|
||||||
|
| 4 | `TestUserJSONLimitedProfile` | Begrenset profil → bio og avatar utelatt |
|
||||||
|
| 5 | `TestFaveJSONComplete` | Alle felter mappet korrekt |
|
||||||
|
| 6 | `TestQueryIntFallback` | Ugyldig/manglende parameter → standardverdi |
|
||||||
|
| 7 | `TestQueryIntNegative` | Negativ verdi → standardverdi |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integrasjonstester (tverrgående)
|
||||||
|
|
||||||
|
Disse testene verifiserer hele flyten fra HTTP-forespørsel gjennom mellomvare, handler, store og tilbake.
|
||||||
|
|
||||||
|
| # | Test | Verifiserer |
|
||||||
|
|---|------|-------------|
|
||||||
|
| 1 | `TestFullLoginFlow` | GET login → POST credentials → session cookie → GET /faves → 200 |
|
||||||
|
| 2 | `TestFullSignupApprovalFlow` | Registrering → admin godkjenner → bruker logger inn → endrer passord |
|
||||||
|
| 3 | `TestFullFaveCRUDFlow` | Opprett → les → rediger → slett favoritt via web |
|
||||||
|
| 4 | `TestFullAPIFaveCRUDFlow` | Login → opprett → hent → oppdater → slett via JSON API |
|
||||||
|
| 5 | `TestImportExportRoundtrip` | Eksporter → importer til ny bruker → verifiser likhet |
|
||||||
|
| 6 | `TestFeedReflectsNewFaves` | Opprett favoritt → feed oppdateres |
|
||||||
|
| 7 | `TestRateLimitOnLogin` | Mange mislykkede pålogginger → 429 |
|
||||||
|
| 8 | `TestCSRFProtectionEndToEnd` | Hent side → ekstraher token → POST med token → suksess |
|
||||||
|
| 9 | `TestBasePathPropagation` | Alle lenker og redirects respekterer base path |
|
||||||
|
| 10 | `TestMultiUserPrivacy` | Bruker A ser ikke bruker Bs private favoritter i noen visning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testinfrastruktur
|
||||||
|
|
||||||
|
### Eksisterende hjelpere (gjenbruk)
|
||||||
|
|
||||||
|
- `testDB(t)` — in-memory SQLite med migrasjoner (`store/*_test.go`)
|
||||||
|
- `testServer(t)` — komplett handler-stack med mellomvare (`handler/handler_test.go`)
|
||||||
|
- `loginUser(t, h, username, password, role)` — oppretter bruker og returnerer session-cookie
|
||||||
|
- `extractCookie(rr, name)` — henter cookie fra response
|
||||||
|
|
||||||
|
### Nye hjelpere som trengs
|
||||||
|
|
||||||
|
| Hjelper | Bruksområde |
|
||||||
|
|---------|-------------|
|
||||||
|
| `testAPIServer(t)` | Setup for API-handler med in-memory DB |
|
||||||
|
| `apiLogin(t, server, username, password)` | Hent session-token for API-tester |
|
||||||
|
| `testImage(t, width, height, mime)` | Generer test-bilde med gitte dimensjoner |
|
||||||
|
| `setEnv(t, key, value)` | Sett env-variabel med automatisk cleanup |
|
||||||
|
|
||||||
|
### Konvensjoner
|
||||||
|
|
||||||
|
- Bruk `:memory:` SQLite for alle tester
|
||||||
|
- Sett raske Argon2-parametre (`Memory=1024, Time=1`) i alle passord-tester
|
||||||
|
- Bruk `t.Helper()` på alle hjelpefunksjoner
|
||||||
|
- Bruk `t.Cleanup()` for opprydding, aldri manuell defer i hjelpere
|
||||||
|
- Bruk `httptest.NewRecorder()` og `httptest.NewRequest()` for HTTP-tester
|
||||||
|
- Bruk `t.TempDir()` for bildebehandlingstester
|
||||||
|
- Tabell-drevne tester der det er naturlig (f.eks. config-parsing, feilkoder)
|
||||||
|
- Norske testbeskrivelser i kommentarer, engelske funksjons-/variabelnavn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementeringsrekkefølge
|
||||||
|
|
||||||
|
```
|
||||||
|
Fase 1 (P0): Sikkerhet
|
||||||
|
├── middleware/csrf_test.go (~7 tester)
|
||||||
|
├── middleware/auth_test.go (~7 tester) ← utvid eksisterende fil
|
||||||
|
└── handler/api/api_test.go (~5 auth-tester)
|
||||||
|
|
||||||
|
Fase 2 (P1a): Store + API-kontrakter
|
||||||
|
├── store/settings_test.go (~3 tester)
|
||||||
|
└── handler/api/api_test.go (~25 tester, utvid)
|
||||||
|
|
||||||
|
Fase 3 (P1b): Web-handlere
|
||||||
|
├── handler/handler_test.go (~30 tester, utvid eksisterende)
|
||||||
|
└── handler/auth_test.go (~8 tester)
|
||||||
|
|
||||||
|
Fase 4 (P2): Konfig, bilde, database
|
||||||
|
├── config/config_test.go (~8 tester)
|
||||||
|
├── image/image_test.go (~8 tester)
|
||||||
|
├── database/database_test.go (~5 tester)
|
||||||
|
└── middleware/context_test.go (~4 tester)
|
||||||
|
|
||||||
|
Fase 5 (P3): Rendering og hjelpere
|
||||||
|
├── render/render_test.go (~5 tester)
|
||||||
|
└── handler/api/helpers_test.go (~7 tester)
|
||||||
|
|
||||||
|
Fase 6: Integrasjonstester
|
||||||
|
└── integration_test.go (~10 tester, build tag)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Totalt**: ~132 nye tester → fra 44 til ~176 testfunksjoner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kjøring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle tester
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Én pakke
|
||||||
|
go test ./internal/store/...
|
||||||
|
|
||||||
|
# Med dekning
|
||||||
|
go test -cover ./...
|
||||||
|
|
||||||
|
# Detaljert dekningsrapport
|
||||||
|
go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out
|
||||||
|
|
||||||
|
# Kun integrasjonstester (om build tag brukes)
|
||||||
|
go test -tags=integration ./...
|
||||||
|
```
|
||||||
204
internal/config/config_test.go
Normal file
204
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
// Clear all FAVORITTER_ env vars to test defaults.
|
||||||
|
for _, key := range []string{
|
||||||
|
"FAVORITTER_DB_PATH", "FAVORITTER_LISTEN", "FAVORITTER_BASE_PATH",
|
||||||
|
"FAVORITTER_EXTERNAL_URL", "FAVORITTER_UPLOAD_DIR", "FAVORITTER_MAX_UPLOAD_SIZE",
|
||||||
|
"FAVORITTER_SESSION_LIFETIME", "FAVORITTER_ARGON2_MEMORY", "FAVORITTER_ARGON2_TIME",
|
||||||
|
"FAVORITTER_ARGON2_PARALLELISM", "FAVORITTER_RATE_LIMIT", "FAVORITTER_ADMIN_USERNAME",
|
||||||
|
"FAVORITTER_ADMIN_PASSWORD", "FAVORITTER_SITE_NAME", "FAVORITTER_DEV_MODE",
|
||||||
|
"FAVORITTER_TRUSTED_PROXIES",
|
||||||
|
} {
|
||||||
|
t.Setenv(key, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
if cfg.DBPath != "./data/favoritter.db" {
|
||||||
|
t.Errorf("DBPath = %q, want default", cfg.DBPath)
|
||||||
|
}
|
||||||
|
if cfg.Listen != ":8080" {
|
||||||
|
t.Errorf("Listen = %q, want :8080", cfg.Listen)
|
||||||
|
}
|
||||||
|
if cfg.BasePath != "" {
|
||||||
|
t.Errorf("BasePath = %q, want empty (root)", cfg.BasePath)
|
||||||
|
}
|
||||||
|
if cfg.MaxUploadSize != 10<<20 {
|
||||||
|
t.Errorf("MaxUploadSize = %d, want %d", cfg.MaxUploadSize, 10<<20)
|
||||||
|
}
|
||||||
|
if cfg.SessionLifetime != 720*time.Hour {
|
||||||
|
t.Errorf("SessionLifetime = %v, want 720h", cfg.SessionLifetime)
|
||||||
|
}
|
||||||
|
if cfg.SiteName != "Favoritter" {
|
||||||
|
t.Errorf("SiteName = %q, want Favoritter", cfg.SiteName)
|
||||||
|
}
|
||||||
|
if cfg.DevMode {
|
||||||
|
t.Error("DevMode should be false by default")
|
||||||
|
}
|
||||||
|
if cfg.RateLimit != 60 {
|
||||||
|
t.Errorf("RateLimit = %d, want 60", cfg.RateLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFromEnv(t *testing.T) {
|
||||||
|
t.Setenv("FAVORITTER_DB_PATH", "/custom/db.sqlite")
|
||||||
|
t.Setenv("FAVORITTER_LISTEN", ":9090")
|
||||||
|
t.Setenv("FAVORITTER_BASE_PATH", "/faves")
|
||||||
|
t.Setenv("FAVORITTER_EXTERNAL_URL", "https://faves.example.com/")
|
||||||
|
t.Setenv("FAVORITTER_UPLOAD_DIR", "/custom/uploads")
|
||||||
|
t.Setenv("FAVORITTER_MAX_UPLOAD_SIZE", "20971520")
|
||||||
|
t.Setenv("FAVORITTER_SESSION_LIFETIME", "48h")
|
||||||
|
t.Setenv("FAVORITTER_ARGON2_MEMORY", "131072")
|
||||||
|
t.Setenv("FAVORITTER_ARGON2_TIME", "5")
|
||||||
|
t.Setenv("FAVORITTER_ARGON2_PARALLELISM", "4")
|
||||||
|
t.Setenv("FAVORITTER_RATE_LIMIT", "100")
|
||||||
|
t.Setenv("FAVORITTER_ADMIN_USERNAME", "admin")
|
||||||
|
t.Setenv("FAVORITTER_ADMIN_PASSWORD", "secret")
|
||||||
|
t.Setenv("FAVORITTER_SITE_NAME", "Mine Favoritter")
|
||||||
|
t.Setenv("FAVORITTER_DEV_MODE", "true")
|
||||||
|
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "10.0.0.0/8,192.168.1.0/24")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
if cfg.DBPath != "/custom/db.sqlite" {
|
||||||
|
t.Errorf("DBPath = %q", cfg.DBPath)
|
||||||
|
}
|
||||||
|
if cfg.Listen != ":9090" {
|
||||||
|
t.Errorf("Listen = %q", cfg.Listen)
|
||||||
|
}
|
||||||
|
if cfg.BasePath != "/faves" {
|
||||||
|
t.Errorf("BasePath = %q, want /faves", cfg.BasePath)
|
||||||
|
}
|
||||||
|
// External URL should have trailing slash stripped.
|
||||||
|
if cfg.ExternalURL != "https://faves.example.com" {
|
||||||
|
t.Errorf("ExternalURL = %q, want trailing slash stripped", cfg.ExternalURL)
|
||||||
|
}
|
||||||
|
if cfg.MaxUploadSize != 20971520 {
|
||||||
|
t.Errorf("MaxUploadSize = %d", cfg.MaxUploadSize)
|
||||||
|
}
|
||||||
|
if cfg.SessionLifetime != 48*time.Hour {
|
||||||
|
t.Errorf("SessionLifetime = %v", cfg.SessionLifetime)
|
||||||
|
}
|
||||||
|
if cfg.Argon2Memory != 131072 {
|
||||||
|
t.Errorf("Argon2Memory = %d", cfg.Argon2Memory)
|
||||||
|
}
|
||||||
|
if cfg.Argon2Time != 5 {
|
||||||
|
t.Errorf("Argon2Time = %d", cfg.Argon2Time)
|
||||||
|
}
|
||||||
|
if cfg.Argon2Parallelism != 4 {
|
||||||
|
t.Errorf("Argon2Parallelism = %d", cfg.Argon2Parallelism)
|
||||||
|
}
|
||||||
|
if cfg.RateLimit != 100 {
|
||||||
|
t.Errorf("RateLimit = %d", cfg.RateLimit)
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername != "admin" {
|
||||||
|
t.Errorf("AdminUsername = %q", cfg.AdminUsername)
|
||||||
|
}
|
||||||
|
if cfg.AdminPassword != "secret" {
|
||||||
|
t.Errorf("AdminPassword = %q", cfg.AdminPassword)
|
||||||
|
}
|
||||||
|
if cfg.SiteName != "Mine Favoritter" {
|
||||||
|
t.Errorf("SiteName = %q", cfg.SiteName)
|
||||||
|
}
|
||||||
|
if !cfg.DevMode {
|
||||||
|
t.Error("DevMode should be true")
|
||||||
|
}
|
||||||
|
if len(cfg.TrustedProxies) != 2 {
|
||||||
|
t.Errorf("TrustedProxies: got %d, want 2", len(cfg.TrustedProxies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedProxiesParsing(t *testing.T) {
|
||||||
|
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "10.0.0.0/8, 192.168.0.0/16, 127.0.0.1")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
if len(cfg.TrustedProxies) != 3 {
|
||||||
|
t.Fatalf("TrustedProxies: got %d, want 3", len(cfg.TrustedProxies))
|
||||||
|
}
|
||||||
|
// 127.0.0.1 without CIDR should become 127.0.0.1/32.
|
||||||
|
last := cfg.TrustedProxies[2]
|
||||||
|
if ones, _ := last.Mask.Size(); ones != 32 {
|
||||||
|
t.Errorf("bare IP mask = /%d, want /32", ones)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustedProxiesInvalid(t *testing.T) {
|
||||||
|
t.Setenv("FAVORITTER_TRUSTED_PROXIES", "not-an-ip, 10.0.0.0/8")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
// Invalid entries are skipped; valid ones remain.
|
||||||
|
if len(cfg.TrustedProxies) != 1 {
|
||||||
|
t.Errorf("TrustedProxies: got %d, want 1 (invalid skipped)", len(cfg.TrustedProxies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasePathNormalization(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/", ""},
|
||||||
|
{"", ""},
|
||||||
|
{"/faves", "/faves"},
|
||||||
|
{"/faves/", "/faves"},
|
||||||
|
{"faves", "/faves"},
|
||||||
|
{"/sub/path/", "/sub/path"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := normalizePath(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("normalizePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevModeFlag(t *testing.T) {
|
||||||
|
t.Setenv("FAVORITTER_DEV_MODE", "true")
|
||||||
|
cfg := Load()
|
||||||
|
if !cfg.DevMode {
|
||||||
|
t.Error("DevMode should be true when env is 'true'")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("FAVORITTER_DEV_MODE", "false")
|
||||||
|
cfg = Load()
|
||||||
|
if cfg.DevMode {
|
||||||
|
t.Error("DevMode should be false when env is 'false'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalHostname(t *testing.T) {
|
||||||
|
cfg := &Config{ExternalURL: "https://faves.example.com/base"}
|
||||||
|
if got := cfg.ExternalHostname(); got != "faves.example.com" {
|
||||||
|
t.Errorf("ExternalHostname = %q, want faves.example.com", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg = &Config{}
|
||||||
|
if got := cfg.ExternalHostname(); got != "" {
|
||||||
|
t.Errorf("empty ExternalURL: ExternalHostname = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseURL(t *testing.T) {
|
||||||
|
// With external URL configured.
|
||||||
|
cfg := &Config{ExternalURL: "https://faves.example.com"}
|
||||||
|
if got := cfg.BaseURL("localhost:8080"); got != "https://faves.example.com" {
|
||||||
|
t.Errorf("BaseURL with external = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without external URL — falls back to request host.
|
||||||
|
cfg = &Config{BasePath: "/faves"}
|
||||||
|
if got := cfg.BaseURL("myhost.local:8080"); got != "https://myhost.local:8080/faves" {
|
||||||
|
t.Errorf("BaseURL fallback = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/database/database_test.go
Normal file
140
internal/database/database_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenInMemory(t *testing.T) {
|
||||||
|
db, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open(:memory:): %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Verify the connection is usable.
|
||||||
|
var result int
|
||||||
|
if err := db.QueryRow("SELECT 1").Scan(&result); err != nil {
|
||||||
|
t.Fatalf("query: %v", err)
|
||||||
|
}
|
||||||
|
if result != 1 {
|
||||||
|
t.Errorf("SELECT 1 = %d", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateCreatesAllTables(t *testing.T) {
|
||||||
|
db, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := Migrate(db); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that core tables exist.
|
||||||
|
tables := []string{"users", "faves", "tags", "fave_tags", "sessions", "site_settings", "schema_migrations", "signup_requests"}
|
||||||
|
for _, table := range tables {
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("check table %s: %v", table, err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("table %s does not exist", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateIdempotent(t *testing.T) {
|
||||||
|
db, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// First migration.
|
||||||
|
if err := Migrate(db); err != nil {
|
||||||
|
t.Fatalf("first migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second migration should be a no-op.
|
||||||
|
if err := Migrate(db); err != nil {
|
||||||
|
t.Fatalf("second migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify schema_migrations has entries (no duplicates).
|
||||||
|
var count int
|
||||||
|
db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
|
||||||
|
if count < 1 {
|
||||||
|
t.Error("expected at least one migration record")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPRAGMAs(t *testing.T) {
|
||||||
|
db, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// WAL mode.
|
||||||
|
var journalMode string
|
||||||
|
db.QueryRow("PRAGMA journal_mode").Scan(&journalMode)
|
||||||
|
// In-memory databases use "memory" journal mode, not "wal".
|
||||||
|
// WAL is only meaningful for file-based databases.
|
||||||
|
// We just verify the pragma was accepted without error.
|
||||||
|
|
||||||
|
// Foreign keys should be ON.
|
||||||
|
var fk int
|
||||||
|
db.QueryRow("PRAGMA foreign_keys").Scan(&fk)
|
||||||
|
if fk != 1 {
|
||||||
|
t.Errorf("foreign_keys = %d, want 1", fk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busy timeout.
|
||||||
|
var timeout int
|
||||||
|
db.QueryRow("PRAGMA busy_timeout").Scan(&timeout)
|
||||||
|
if timeout != 5000 {
|
||||||
|
t.Errorf("busy_timeout = %d, want 5000", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSingleConnection(t *testing.T) {
|
||||||
|
db, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
stats := db.Stats()
|
||||||
|
if stats.MaxOpenConnections != 1 {
|
||||||
|
t.Errorf("MaxOpenConnections = %d, want 1", stats.MaxOpenConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteSettingsSeeded(t *testing.T) {
|
||||||
|
db, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := Migrate(db); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrations should seed a default site_settings row.
|
||||||
|
var siteName string
|
||||||
|
err = db.QueryRow("SELECT site_name FROM site_settings WHERE id = 1").Scan(&siteName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query site_settings: %v", err)
|
||||||
|
}
|
||||||
|
if siteName == "" {
|
||||||
|
t.Error("expected non-empty default site_name")
|
||||||
|
}
|
||||||
|
}
|
||||||
663
internal/handler/api/api_test.go
Normal file
663
internal/handler/api/api_test.go
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/config"
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/database"
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/middleware"
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testAPIServer creates a wired API handler with in-memory DB.
|
||||||
|
func testAPIServer(t *testing.T) (*Handler, *http.ServeMux, *store.UserStore, *store.SessionStore) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := database.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.Migrate(db); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
store.Argon2Memory = 1024
|
||||||
|
store.Argon2Time = 1
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
MaxUploadSize: 10 << 20, // 10 MB
|
||||||
|
}
|
||||||
|
|
||||||
|
users := store.NewUserStore(db)
|
||||||
|
sessions := store.NewSessionStore(db)
|
||||||
|
faves := store.NewFaveStore(db)
|
||||||
|
tags := store.NewTagStore(db)
|
||||||
|
|
||||||
|
h := New(Deps{
|
||||||
|
Config: cfg,
|
||||||
|
Users: users,
|
||||||
|
Sessions: sessions,
|
||||||
|
Faves: faves,
|
||||||
|
Tags: tags,
|
||||||
|
})
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.Routes(mux)
|
||||||
|
|
||||||
|
// Wrap with SessionLoader so authenticated API requests work.
|
||||||
|
chain := middleware.SessionLoader(sessions, users)(mux)
|
||||||
|
wrappedMux := http.NewServeMux()
|
||||||
|
wrappedMux.Handle("/", chain)
|
||||||
|
|
||||||
|
return h, wrappedMux, users, sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiLogin creates a user and returns a session cookie.
|
||||||
|
func apiLogin(t *testing.T, users *store.UserStore, sessions *store.SessionStore, username, password, role string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
user, err := users.Create(username, password, role)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user %s: %v", username, err)
|
||||||
|
}
|
||||||
|
token, err := sessions.Create(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: "session", Value: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonBody is a helper to parse JSON response bodies.
|
||||||
|
func jsonBody(t *testing.T, rr *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatalf("parse response JSON: %v\nbody: %s", err, rr.Body.String())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
func TestAPILoginSuccess(t *testing.T) {
|
||||||
|
_, mux, users, _ := testAPIServer(t)
|
||||||
|
users.Create("testuser", "password123", "user")
|
||||||
|
|
||||||
|
body := `{"username":"testuser","password":"password123"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login: got %d, want 200\nbody: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
if result["token"] == nil || result["token"] == "" {
|
||||||
|
t.Error("expected token in response")
|
||||||
|
}
|
||||||
|
user, ok := result["user"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected user object in response")
|
||||||
|
}
|
||||||
|
if user["username"] != "testuser" {
|
||||||
|
t.Errorf("username = %v, want testuser", user["username"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPILoginWrongPassword(t *testing.T) {
|
||||||
|
_, mux, users, _ := testAPIServer(t)
|
||||||
|
users.Create("testuser", "password123", "user")
|
||||||
|
|
||||||
|
body := `{"username":"testuser","password":"wrong"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(body))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("wrong password: got %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPILoginInvalidBody(t *testing.T) {
|
||||||
|
_, mux, _, _ := testAPIServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader("not json"))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("invalid body: got %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPILogout(t *testing.T) {
|
||||||
|
_, mux, users, sessions := testAPIServer(t)
|
||||||
|
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/auth/logout", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("logout: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session should be invalid now.
|
||||||
|
_, err := sessions.Validate(cookie.Value)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("session should be invalidated after logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Faves CRUD ---
|
||||||
|
|
||||||
|
func TestAPICreateFave(t *testing.T) {
|
||||||
|
_, mux, users, sessions := testAPIServer(t)
|
||||||
|
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
|
||||||
|
|
||||||
|
body := `{"description":"My favorite thing","url":"https://example.com","privacy":"public","tags":["go","web"]}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body))
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create fave: got %d, want 201\nbody: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
if result["description"] != "My favorite thing" {
|
||||||
|
t.Errorf("description = %v", result["description"])
|
||||||
|
}
|
||||||
|
if result["url"] != "https://example.com" {
|
||||||
|
t.Errorf("url = %v", result["url"])
|
||||||
|
}
|
||||||
|
tags, ok := result["tags"].([]any)
|
||||||
|
if !ok || len(tags) != 2 {
|
||||||
|
t.Errorf("expected 2 tags, got %v", result["tags"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPICreateFaveMissingDescription(t *testing.T) {
|
||||||
|
_, mux, users, sessions := testAPIServer(t)
|
||||||
|
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
|
||||||
|
|
||||||
|
body := `{"url":"https://example.com"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body))
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("missing description: got %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPICreateFaveRequiresAuth(t *testing.T) {
|
||||||
|
_, mux, _, _ := testAPIServer(t)
|
||||||
|
|
||||||
|
body := `{"description":"test"}`
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(body))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
// Should redirect or return non-2xx.
|
||||||
|
if rr.Code == http.StatusCreated || rr.Code == http.StatusOK {
|
||||||
|
t.Errorf("unauthenticated create: got %d, should not be 2xx", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIGetFave(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
|
||||||
|
|
||||||
|
// Create a public fave directly.
|
||||||
|
user, _ := users.GetByUsername("testuser")
|
||||||
|
fave, _ := h.deps.Faves.Create(user.ID, "Test fave", "https://example.com", "", "public")
|
||||||
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"test"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get fave: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
if result["description"] != "Test fave" {
|
||||||
|
t.Errorf("description = %v", result["description"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIGetFaveNotFound(t *testing.T) {
|
||||||
|
_, mux, _, _ := testAPIServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/faves/99999", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("nonexistent fave: got %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIPrivateFaveHiddenFromOthers(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
// User A creates a private fave.
|
||||||
|
userA, _ := users.Create("usera", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(userA.ID, "Secret", "", "", "private")
|
||||||
|
|
||||||
|
// User B tries to access it.
|
||||||
|
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
|
||||||
|
req.AddCookie(cookieB)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("private fave for other user: got %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIPrivateFaveVisibleToOwner(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
userA, _ := users.Create("usera", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "private")
|
||||||
|
|
||||||
|
tokenA, _ := sessions.Create(userA.ID)
|
||||||
|
cookieA := &http.Cookie{Name: "session", Value: tokenA}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
|
||||||
|
req.AddCookie(cookieA)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("own private fave: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIUpdateFave(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(user.ID, "Original", "https://old.com", "", "public")
|
||||||
|
token, _ := sessions.Create(user.ID)
|
||||||
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
body := `{"description":"Updated","url":"https://new.com","tags":["updated"]}`
|
||||||
|
req := httptest.NewRequest("PUT", "/api/v1/faves/"+faveIDStr(fave.ID), strings.NewReader(body))
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update fave: got %d, want 200\nbody: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
if result["description"] != "Updated" {
|
||||||
|
t.Errorf("description = %v, want Updated", result["description"])
|
||||||
|
}
|
||||||
|
if result["url"] != "https://new.com" {
|
||||||
|
t.Errorf("url = %v, want https://new.com", result["url"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIUpdateFaveNotOwner(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
userA, _ := users.Create("usera", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "public")
|
||||||
|
|
||||||
|
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
|
||||||
|
|
||||||
|
body := `{"description":"Hijacked"}`
|
||||||
|
req := httptest.NewRequest("PUT", "/api/v1/faves/"+faveIDStr(fave.ID), strings.NewReader(body))
|
||||||
|
req.AddCookie(cookieB)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("update by non-owner: got %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDeleteFave(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(user.ID, "Delete me", "", "", "public")
|
||||||
|
token, _ := sessions.Create(user.ID)
|
||||||
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("DELETE", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("delete fave: got %d, want 204", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's gone.
|
||||||
|
req = httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("deleted fave: got %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDeleteFaveNotOwner(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
userA, _ := users.Create("usera", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "public")
|
||||||
|
|
||||||
|
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("DELETE", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
|
||||||
|
req.AddCookie(cookieB)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("delete by non-owner: got %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIListFaves(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
|
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "public")
|
||||||
|
h.deps.Faves.Create(user.ID, "Fave 2", "", "", "public")
|
||||||
|
h.deps.Faves.Create(user.ID, "Fave 3", "", "", "private")
|
||||||
|
token, _ := sessions.Create(user.ID)
|
||||||
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/faves", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list faves: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
total, _ := result["total"].(float64)
|
||||||
|
if total != 3 {
|
||||||
|
t.Errorf("total = %v, want 3 (all faves including private)", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIListFavesPagination(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
h.deps.Faves.Create(user.ID, "Fave", "", "", "public")
|
||||||
|
}
|
||||||
|
token, _ := sessions.Create(user.ID)
|
||||||
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/faves?page=1&limit=2", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
faves, ok := result["faves"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected faves array")
|
||||||
|
}
|
||||||
|
if len(faves) != 2 {
|
||||||
|
t.Errorf("page size: got %d faves, want 2", len(faves))
|
||||||
|
}
|
||||||
|
total, _ := result["total"].(float64)
|
||||||
|
if total != 5 {
|
||||||
|
t.Errorf("total = %v, want 5", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tags ---
|
||||||
|
|
||||||
|
func TestAPISearchTags(t *testing.T) {
|
||||||
|
h, mux, users, _ := testAPIServer(t)
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
|
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "public")
|
||||||
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang", "goroutines", "python"})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/tags?q=go", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("search tags: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
tags, ok := result["tags"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected tags array")
|
||||||
|
}
|
||||||
|
if len(tags) < 1 {
|
||||||
|
t.Error("expected at least one tag matching 'go'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISearchTagsEmpty(t *testing.T) {
|
||||||
|
_, mux, _, _ := testAPIServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/tags?q=nonexistent", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("empty tag search: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
tags, _ := result["tags"].([]any)
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected empty tags, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
|
||||||
|
func TestAPIGetUser(t *testing.T) {
|
||||||
|
_, mux, users, _ := testAPIServer(t)
|
||||||
|
users.Create("testuser", "pass123", "user")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/users/testuser", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get user: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
if result["username"] != "testuser" {
|
||||||
|
t.Errorf("username = %v", result["username"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIGetUserNotFound(t *testing.T) {
|
||||||
|
_, mux, _, _ := testAPIServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/users/nobody", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("nonexistent user: got %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIGetDisabledUser(t *testing.T) {
|
||||||
|
_, mux, users, _ := testAPIServer(t)
|
||||||
|
user, _ := users.Create("disabled", "pass123", "user")
|
||||||
|
users.SetDisabled(user.ID, true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/users/disabled", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("disabled user: got %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIGetUserFaves(t *testing.T) {
|
||||||
|
h, mux, users, _ := testAPIServer(t)
|
||||||
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
|
h.deps.Faves.Create(user.ID, "Public fave", "", "", "public")
|
||||||
|
h.deps.Faves.Create(user.ID, "Private fave", "", "", "private")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/users/testuser/faves", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("user faves: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
total, _ := result["total"].(float64)
|
||||||
|
if total != 1 {
|
||||||
|
t.Errorf("total = %v, want 1 (only public faves)", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Export/Import ---
|
||||||
|
|
||||||
|
func TestAPIExport(t *testing.T) {
|
||||||
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
|
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "public")
|
||||||
|
h.deps.Faves.Create(user.ID, "Fave 2", "", "", "private")
|
||||||
|
token, _ := sessions.Create(user.ID)
|
||||||
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/v1/export/json", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("export: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export returns a JSON array directly.
|
||||||
|
var faves []any
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &faves); err != nil {
|
||||||
|
t.Fatalf("parse export JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(faves) != 2 {
|
||||||
|
t.Errorf("exported %d faves, want 2", len(faves))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIImportValid(t *testing.T) {
|
||||||
|
_, mux, users, sessions := testAPIServer(t)
|
||||||
|
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
|
||||||
|
|
||||||
|
body := `[{"description":"Imported 1","privacy":"public"},{"description":"Imported 2","tags":["test"]}]`
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader(body))
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("import: got %d, want 200\nbody: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
imported, _ := result["imported"].(float64)
|
||||||
|
if imported != 2 {
|
||||||
|
t.Errorf("imported = %v, want 2", imported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIImportSkipsEmpty(t *testing.T) {
|
||||||
|
_, mux, users, sessions := testAPIServer(t)
|
||||||
|
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
|
||||||
|
|
||||||
|
body := `[{"description":"Valid"},{"description":"","url":"https://empty.com"}]`
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader(body))
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
result := jsonBody(t, rr)
|
||||||
|
imported, _ := result["imported"].(float64)
|
||||||
|
total, _ := result["total"].(float64)
|
||||||
|
if imported != 1 {
|
||||||
|
t.Errorf("imported = %v, want 1", imported)
|
||||||
|
}
|
||||||
|
if total != 2 {
|
||||||
|
t.Errorf("total = %v, want 2", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIImportInvalidJSON(t *testing.T) {
|
||||||
|
_, mux, users, sessions := testAPIServer(t)
|
||||||
|
cookie := apiLogin(t, users, sessions, "testuser", "pass123", "user")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/import", strings.NewReader("not json"))
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("invalid JSON import: got %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON helpers ---
|
||||||
|
|
||||||
|
func TestQueryIntFallback(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
query string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"", 10},
|
||||||
|
{"page=abc", 10},
|
||||||
|
{"page=-1", 10},
|
||||||
|
{"page=0", 10},
|
||||||
|
{"page=5", 5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
req := httptest.NewRequest("GET", "/test?"+tt.query, nil)
|
||||||
|
got := queryInt(req, "page", 10)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("queryInt(%q) = %d, want %d", tt.query, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// faveIDStr converts an int64 to a string for URL paths.
|
||||||
|
func faveIDStr(id int64) string {
|
||||||
|
return strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
|
@ -67,8 +67,8 @@ func testServer(t *testing.T) (*Handler, *http.ServeMux) {
|
||||||
|
|
||||||
mux := h.Routes()
|
mux := h.Routes()
|
||||||
|
|
||||||
// Wrap with SessionLoader so authenticated tests work.
|
// Wrap with SessionLoader and CSRFProtection so authenticated tests work.
|
||||||
chain := middleware.SessionLoader(sessions, users)(mux)
|
chain := middleware.CSRFProtection(cfg)(middleware.SessionLoader(sessions, users)(mux))
|
||||||
wrappedMux := http.NewServeMux()
|
wrappedMux := http.NewServeMux()
|
||||||
wrappedMux.Handle("/", chain)
|
wrappedMux.Handle("/", chain)
|
||||||
|
|
||||||
|
|
|
||||||
1153
internal/handler/web_test.go
Normal file
1153
internal/handler/web_test.go
Normal file
File diff suppressed because it is too large
Load diff
234
internal/image/image_test.go
Normal file
234
internal/image/image_test.go
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package image
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/textproto"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testJPEG creates a test JPEG image in memory with given dimensions.
|
||||||
|
func testJPEG(t *testing.T, width, height int) (*bytes.Buffer, *multipart.FileHeader) {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
|
||||||
|
t.Fatalf("encode test jpeg: %v", err)
|
||||||
|
}
|
||||||
|
header := &multipart.FileHeader{
|
||||||
|
Filename: "test.jpg",
|
||||||
|
Size: int64(buf.Len()),
|
||||||
|
Header: textproto.MIMEHeader{"Content-Type": {"image/jpeg"}},
|
||||||
|
}
|
||||||
|
return &buf, header
|
||||||
|
}
|
||||||
|
|
||||||
|
// testPNG creates a test PNG image in memory with given dimensions.
|
||||||
|
func testPNG(t *testing.T, width, height int) (*bytes.Buffer, *multipart.FileHeader) {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatalf("encode test png: %v", err)
|
||||||
|
}
|
||||||
|
header := &multipart.FileHeader{
|
||||||
|
Filename: "test.png",
|
||||||
|
Size: int64(buf.Len()),
|
||||||
|
Header: textproto.MIMEHeader{"Content-Type": {"image/png"}},
|
||||||
|
}
|
||||||
|
return &buf, header
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufferReadSeeker wraps a bytes.Reader to implement multipart.File.
|
||||||
|
type bufferReadSeeker struct {
|
||||||
|
*bytes.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bufferReadSeeker) Close() error { return nil }
|
||||||
|
|
||||||
|
func TestProcessJPEG(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
buf, header := testJPEG(t, 800, 600)
|
||||||
|
|
||||||
|
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Filename == "" {
|
||||||
|
t.Error("expected non-empty filename")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(result.Filename, ".jpg") {
|
||||||
|
t.Errorf("filename %q should end with .jpg", result.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was written.
|
||||||
|
if _, err := os.Stat(result.Path); err != nil {
|
||||||
|
t.Errorf("output file not found: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessPNG(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
buf, header := testPNG(t, 640, 480)
|
||||||
|
|
||||||
|
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process PNG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(result.Filename, ".png") {
|
||||||
|
t.Errorf("filename %q should end with .png", result.Filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessResizeWideImage(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
buf, header := testJPEG(t, 3840, 2160) // 4K width
|
||||||
|
|
||||||
|
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process wide image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and check dimensions.
|
||||||
|
f, err := os.Open(result.Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open result: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
img, _, err := image.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds := img.Bounds()
|
||||||
|
if bounds.Dx() != MaxWidth {
|
||||||
|
t.Errorf("resized width = %d, want %d", bounds.Dx(), MaxWidth)
|
||||||
|
}
|
||||||
|
// Aspect ratio should be maintained.
|
||||||
|
expectedHeight := 2160 * MaxWidth / 3840
|
||||||
|
if bounds.Dy() != expectedHeight {
|
||||||
|
t.Errorf("resized height = %d, want %d", bounds.Dy(), expectedHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessSmallImageNotResized(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
buf, header := testJPEG(t, 800, 600)
|
||||||
|
|
||||||
|
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process small image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(result.Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open result: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
img, _, err := image.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if img.Bounds().Dx() != 800 {
|
||||||
|
t.Errorf("small image width = %d, should not be resized", img.Bounds().Dx())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessInvalidMIME(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
header := &multipart.FileHeader{
|
||||||
|
Filename: "test.txt",
|
||||||
|
Header: textproto.MIMEHeader{"Content-Type": {"text/plain"}},
|
||||||
|
}
|
||||||
|
buf := bytes.NewReader([]byte("not an image"))
|
||||||
|
|
||||||
|
_, err := Process(&bufferReadSeeker{buf}, header, uploadDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for text/plain MIME type")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unsupported image type") {
|
||||||
|
t.Errorf("error = %q, should mention unsupported type", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessCorruptImage(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
header := &multipart.FileHeader{
|
||||||
|
Filename: "corrupt.jpg",
|
||||||
|
Header: textproto.MIMEHeader{"Content-Type": {"image/jpeg"}},
|
||||||
|
}
|
||||||
|
buf := bytes.NewReader([]byte("this is not valid jpeg data"))
|
||||||
|
|
||||||
|
_, err := Process(&bufferReadSeeker{buf}, header, uploadDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for corrupt image data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessUUIDFilename(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
buf, header := testJPEG(t, 100, 100)
|
||||||
|
// Give a user-supplied filename.
|
||||||
|
header.Filename = "my-vacation-photo.jpg"
|
||||||
|
|
||||||
|
result, err := Process(&bufferReadSeeker{bytes.NewReader(buf.Bytes())}, header, uploadDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(result.Filename, "vacation") {
|
||||||
|
t.Error("filename should be UUID-based, not user-supplied")
|
||||||
|
}
|
||||||
|
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.ext
|
||||||
|
if len(result.Filename) < 36 {
|
||||||
|
t.Errorf("filename %q too short for UUID", result.Filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowedTypes(t *testing.T) {
|
||||||
|
expected := []string{"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
|
for _, mime := range expected {
|
||||||
|
if _, ok := AllowedTypes[mime]; !ok {
|
||||||
|
t.Errorf("AllowedTypes missing %s", mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePathTraversal(t *testing.T) {
|
||||||
|
uploadDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a file outside uploadDir.
|
||||||
|
outsideFile := filepath.Join(t.TempDir(), "sensitive.txt")
|
||||||
|
os.WriteFile(outsideFile, []byte("secret"), 0644)
|
||||||
|
|
||||||
|
// Attempt to delete it via path traversal.
|
||||||
|
err := Delete(uploadDir, "../../../"+filepath.Base(outsideFile))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for path traversal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// File should still exist.
|
||||||
|
if _, statErr := os.Stat(outsideFile); statErr != nil {
|
||||||
|
t.Error("path traversal should not have deleted the file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteEmpty(t *testing.T) {
|
||||||
|
// Empty filename should be a no-op.
|
||||||
|
if err := Delete(t.TempDir(), ""); err != nil {
|
||||||
|
t.Errorf("Delete empty filename: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
299
internal/middleware/auth_test.go
Normal file
299
internal/middleware/auth_test.go
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/database"
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/model"
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testStores creates in-memory stores for auth middleware tests.
|
||||||
|
func testStores(t *testing.T) (*store.SessionStore, *store.UserStore) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.Migrate(db); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
store.Argon2Memory = 1024
|
||||||
|
store.Argon2Time = 1
|
||||||
|
|
||||||
|
return store.NewSessionStore(db), store.NewUserStore(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLoaderValidToken(t *testing.T) {
|
||||||
|
sessions, users := testStores(t)
|
||||||
|
|
||||||
|
user, err := users.Create("testuser", "pass123", "user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
token, err := sessions.Create(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctxUser *model.User
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctxUser = UserFromContext(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := SessionLoader(sessions, users)(inner)
|
||||||
|
req := httptest.NewRequest("GET", "/faves", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: token})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if ctxUser == nil {
|
||||||
|
t.Fatal("expected user in context, got nil")
|
||||||
|
}
|
||||||
|
if ctxUser.ID != user.ID {
|
||||||
|
t.Errorf("context user ID = %d, want %d", ctxUser.ID, user.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLoaderInvalidToken(t *testing.T) {
|
||||||
|
sessions, users := testStores(t)
|
||||||
|
|
||||||
|
var ctxUser *model.User
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctxUser = UserFromContext(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := SessionLoader(sessions, users)(inner)
|
||||||
|
req := httptest.NewRequest("GET", "/faves", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "invalid-token"})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("invalid token: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if ctxUser != nil {
|
||||||
|
t.Error("expected nil user for invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should clear the invalid session cookie.
|
||||||
|
for _, c := range rr.Result().Cookies() {
|
||||||
|
if c.Name == SessionCookieName && c.MaxAge == -1 {
|
||||||
|
return // Cookie cleared, good.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Error("expected session cookie to be cleared for invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLoaderNoCookie(t *testing.T) {
|
||||||
|
sessions, users := testStores(t)
|
||||||
|
|
||||||
|
var ctxUser *model.User
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctxUser = UserFromContext(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := SessionLoader(sessions, users)(inner)
|
||||||
|
req := httptest.NewRequest("GET", "/faves", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("no cookie: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if ctxUser != nil {
|
||||||
|
t.Error("expected nil user when no cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLoaderSkipsStaticPaths(t *testing.T) {
|
||||||
|
sessions, users := testStores(t)
|
||||||
|
|
||||||
|
var handlerCalled bool
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handlerCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := SessionLoader(sessions, users)(inner)
|
||||||
|
|
||||||
|
for _, path := range []string{"/static/css/style.css", "/uploads/image.jpg"} {
|
||||||
|
handlerCalled = false
|
||||||
|
req := httptest.NewRequest("GET", path, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "some-token"})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if !handlerCalled {
|
||||||
|
t.Errorf("handler not called for %s", path)
|
||||||
|
}
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("%s: got %d, want 200", path, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionLoaderDisabledUser(t *testing.T) {
|
||||||
|
sessions, users := testStores(t)
|
||||||
|
|
||||||
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
|
token, _ := sessions.Create(user.ID)
|
||||||
|
users.SetDisabled(user.ID, true)
|
||||||
|
|
||||||
|
var ctxUser *model.User
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctxUser = UserFromContext(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := SessionLoader(sessions, users)(inner)
|
||||||
|
req := httptest.NewRequest("GET", "/faves", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: token})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if ctxUser != nil {
|
||||||
|
t.Error("disabled user should not be in context")
|
||||||
|
}
|
||||||
|
// Session should be deleted and cookie cleared.
|
||||||
|
for _, c := range rr.Result().Cookies() {
|
||||||
|
if c.Name == SessionCookieName && c.MaxAge == -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Error("expected session cookie to be cleared for disabled user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAdminRejectsNonAdmin(t *testing.T) {
|
||||||
|
handler := RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Regular user in context.
|
||||||
|
user := &model.User{ID: 1, Username: "regular", Role: "user"}
|
||||||
|
ctx := context.WithValue(context.Background(), userKey, user)
|
||||||
|
req := httptest.NewRequest("GET", "/admin", nil).WithContext(ctx)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("non-admin: got %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAdminAllowsAdmin(t *testing.T) {
|
||||||
|
handler := RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
user := &model.User{ID: 1, Username: "admin", Role: "admin"}
|
||||||
|
ctx := context.WithValue(context.Background(), userKey, user)
|
||||||
|
req := httptest.NewRequest("GET", "/admin", nil).WithContext(ctx)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("admin: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireAdminNoUser(t *testing.T) {
|
||||||
|
handler := RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/admin", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("no user: got %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextHelpers(t *testing.T) {
|
||||||
|
// UserFromContext with user.
|
||||||
|
user := &model.User{ID: 42, Username: "tester"}
|
||||||
|
ctx := context.WithValue(context.Background(), userKey, user)
|
||||||
|
got := UserFromContext(ctx)
|
||||||
|
if got == nil || got.ID != 42 {
|
||||||
|
t.Errorf("UserFromContext with user: got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserFromContext without user.
|
||||||
|
if UserFromContext(context.Background()) != nil {
|
||||||
|
t.Error("UserFromContext should return nil for empty context")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRFTokenFromContext.
|
||||||
|
ctx = context.WithValue(context.Background(), csrfTokenKey, "my-token")
|
||||||
|
if CSRFTokenFromContext(ctx) != "my-token" {
|
||||||
|
t.Error("CSRFTokenFromContext failed")
|
||||||
|
}
|
||||||
|
if CSRFTokenFromContext(context.Background()) != "" {
|
||||||
|
t.Error("CSRFTokenFromContext should return empty for missing key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealIPFromContext.
|
||||||
|
ctx = context.WithValue(context.Background(), realIPKey, "1.2.3.4")
|
||||||
|
if RealIPFromContext(ctx) != "1.2.3.4" {
|
||||||
|
t.Error("RealIPFromContext failed")
|
||||||
|
}
|
||||||
|
if RealIPFromContext(context.Background()) != "" {
|
||||||
|
t.Error("RealIPFromContext should return empty for missing key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustResetPasswordGuardRedirects(t *testing.T) {
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
handler := MustResetPasswordGuard("")(inner)
|
||||||
|
|
||||||
|
// User with must_reset_password on a regular path → redirect.
|
||||||
|
user := &model.User{ID: 1, Username: "resetme", MustResetPassword: true}
|
||||||
|
ctx := context.WithValue(context.Background(), userKey, user)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/faves", nil).WithContext(ctx)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("must-reset on /faves: got %d, want 303", rr.Code)
|
||||||
|
}
|
||||||
|
if loc := rr.Header().Get("Location"); loc != "/reset-password" {
|
||||||
|
t.Errorf("redirect location = %q, want /reset-password", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustResetPasswordGuardAllowsPaths(t *testing.T) {
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
handler := MustResetPasswordGuard("")(inner)
|
||||||
|
|
||||||
|
user := &model.User{ID: 1, Username: "resetme", MustResetPassword: true}
|
||||||
|
ctx := context.WithValue(context.Background(), userKey, user)
|
||||||
|
|
||||||
|
// These paths should pass through even with must_reset.
|
||||||
|
allowedPaths := []string{"/reset-password", "/logout", "/health", "/static/css/style.css"}
|
||||||
|
for _, path := range allowedPaths {
|
||||||
|
req := httptest.NewRequest("GET", path, nil).WithContext(ctx)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("must-reset on %s: got %d, want 200", path, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
internal/middleware/csrf_test.go
Normal file
185
internal/middleware/csrf_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCSRFHandler(cfg *config.Config) http.Handler {
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Echo the CSRF token from context so tests can verify it.
|
||||||
|
token := CSRFTokenFromContext(r.Context())
|
||||||
|
w.Write([]byte(token))
|
||||||
|
})
|
||||||
|
return CSRFProtection(cfg)(inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFTokenSetInCookie(t *testing.T) {
|
||||||
|
handler := testCSRFHandler(&config.Config{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should set a csrf_token cookie.
|
||||||
|
var found bool
|
||||||
|
for _, c := range rr.Result().Cookies() {
|
||||||
|
if c.Name == "csrf_token" {
|
||||||
|
found = true
|
||||||
|
if c.Value == "" {
|
||||||
|
t.Error("csrf_token cookie is empty")
|
||||||
|
}
|
||||||
|
if c.HttpOnly {
|
||||||
|
t.Error("csrf_token cookie should not be HttpOnly (JS needs to read it)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("csrf_token cookie not set on first request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The context token should match the cookie.
|
||||||
|
body := rr.Body.String()
|
||||||
|
if body == "" {
|
||||||
|
t.Error("CSRF token not set in context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFValidTokenAccepted(t *testing.T) {
|
||||||
|
handler := testCSRFHandler(&config.Config{})
|
||||||
|
|
||||||
|
// First GET to obtain a token.
|
||||||
|
getReq := httptest.NewRequest("GET", "/", nil)
|
||||||
|
getRR := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(getRR, getReq)
|
||||||
|
|
||||||
|
var token string
|
||||||
|
for _, c := range getRR.Result().Cookies() {
|
||||||
|
if c.Name == "csrf_token" {
|
||||||
|
token = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("no csrf_token cookie from GET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST with matching cookie + form field.
|
||||||
|
form := "csrf_token=" + token
|
||||||
|
req := httptest.NewRequest("POST", "/submit", strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: token})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("valid CSRF POST: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFMismatchRejected(t *testing.T) {
|
||||||
|
handler := testCSRFHandler(&config.Config{})
|
||||||
|
|
||||||
|
form := "csrf_token=wrong-token"
|
||||||
|
req := httptest.NewRequest("POST", "/submit", strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: "real-token"})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("mismatched CSRF: got %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFMissingTokenRejected(t *testing.T) {
|
||||||
|
handler := testCSRFHandler(&config.Config{})
|
||||||
|
|
||||||
|
// POST with cookie but no form field or header.
|
||||||
|
req := httptest.NewRequest("POST", "/submit", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: "some-token"})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("missing CSRF form field: got %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFHeaderFallback(t *testing.T) {
|
||||||
|
handler := testCSRFHandler(&config.Config{})
|
||||||
|
|
||||||
|
token := "valid-header-token"
|
||||||
|
req := httptest.NewRequest("POST", "/submit", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: token})
|
||||||
|
req.Header.Set("X-CSRF-Token", token)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("CSRF via header: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFSkippedForAPI(t *testing.T) {
|
||||||
|
handler := testCSRFHandler(&config.Config{})
|
||||||
|
|
||||||
|
// POST to /api/ path — should skip CSRF validation.
|
||||||
|
req := httptest.NewRequest("POST", "/api/v1/faves", strings.NewReader(`{}`))
|
||||||
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: "some-token"})
|
||||||
|
// Intentionally no CSRF form field or header.
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("API route CSRF skip: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFSafeMethodsPassThrough(t *testing.T) {
|
||||||
|
handler := testCSRFHandler(&config.Config{})
|
||||||
|
|
||||||
|
for _, method := range []string{"GET", "HEAD", "OPTIONS"} {
|
||||||
|
req := httptest.NewRequest(method, "/page", nil)
|
||||||
|
// No CSRF cookie or token at all.
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("%s without CSRF: got %d, want 200", method, rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFExistingCookieReused(t *testing.T) {
|
||||||
|
handler := testCSRFHandler(&config.Config{})
|
||||||
|
|
||||||
|
// Send a request with an existing csrf_token cookie.
|
||||||
|
existingToken := "pre-existing-token-value"
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: existingToken})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
// The context token should be the existing one.
|
||||||
|
body := rr.Body.String()
|
||||||
|
if body != existingToken {
|
||||||
|
t.Errorf("context token = %q, want existing %q", body, existingToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should NOT set a new cookie (existing one is reused).
|
||||||
|
for _, c := range rr.Result().Cookies() {
|
||||||
|
if c.Name == "csrf_token" {
|
||||||
|
t.Error("should not set new csrf_token cookie when one already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
internal/render/render_test.go
Normal file
161
internal/render/render_test.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRenderer(t *testing.T) *Renderer {
|
||||||
|
t.Helper()
|
||||||
|
cfg := &config.Config{
|
||||||
|
SiteName: "Test Site",
|
||||||
|
BasePath: "/test",
|
||||||
|
}
|
||||||
|
r, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create renderer: %v", err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPage(t *testing.T) {
|
||||||
|
r := testRenderer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.Page(rr, req, "login", PageData{
|
||||||
|
Title: "Logg inn",
|
||||||
|
})
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("render page: got %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "Logg inn") {
|
||||||
|
t.Error("page should contain title")
|
||||||
|
}
|
||||||
|
ct := rr.Header().Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "text/html") {
|
||||||
|
t.Errorf("content-type = %q, want text/html", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPageWithData(t *testing.T) {
|
||||||
|
r := testRenderer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.Page(rr, req, "login", PageData{
|
||||||
|
Title: "Test Page",
|
||||||
|
SiteName: "My Site",
|
||||||
|
BasePath: "/base",
|
||||||
|
})
|
||||||
|
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "Test Page") {
|
||||||
|
t.Error("should contain title in output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMissingTemplate(t *testing.T) {
|
||||||
|
r := testRenderer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.Page(rr, req, "nonexistent_template_xyz", PageData{})
|
||||||
|
|
||||||
|
if rr.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("missing template: got %d, want 500", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderErrorPage(t *testing.T) {
|
||||||
|
r := testRenderer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r.Error(rr, req, http.StatusNotFound, "Ikke funnet")
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("error page: got %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "Ikke funnet") {
|
||||||
|
t.Error("error page should contain message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPopulatesCommonData(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
SiteName: "Favoritter",
|
||||||
|
BasePath: "/app",
|
||||||
|
ExternalURL: "https://example.com",
|
||||||
|
}
|
||||||
|
r, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create renderer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
// Add a CSRF token to the context.
|
||||||
|
type contextKey string
|
||||||
|
ctx := context.WithValue(req.Context(), contextKey("csrf_token"), "test-token")
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
r.Page(rr, req, "login", PageData{Title: "Test"})
|
||||||
|
|
||||||
|
// BasePath and SiteName should be populated from config.
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "/app") {
|
||||||
|
t.Error("should contain basePath from config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateFuncs(t *testing.T) {
|
||||||
|
cfg := &config.Config{BasePath: "/test", ExternalURL: "https://example.com"}
|
||||||
|
r, _ := New(cfg)
|
||||||
|
|
||||||
|
funcs := r.templateFuncs()
|
||||||
|
|
||||||
|
// Test truncate function.
|
||||||
|
truncate := funcs["truncate"].(func(int, string) string)
|
||||||
|
if got := truncate(5, "Hello, world!"); got != "Hello..." {
|
||||||
|
t.Errorf("truncate(5, long) = %q, want Hello...", got)
|
||||||
|
}
|
||||||
|
if got := truncate(20, "Short"); got != "Short" {
|
||||||
|
t.Errorf("truncate(20, short) = %q, want Short", got)
|
||||||
|
}
|
||||||
|
// Test with Norwegian characters (Ærlig = 5 runes: Æ r l i g).
|
||||||
|
if got := truncate(3, "Ærlig"); got != "Ærl..." {
|
||||||
|
t.Errorf("truncate(3, Ærlig) = %q, want Ærl...", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test add/subtract.
|
||||||
|
add := funcs["add"].(func(int, int) int)
|
||||||
|
if add(2, 3) != 5 {
|
||||||
|
t.Error("add(2,3) should be 5")
|
||||||
|
}
|
||||||
|
sub := funcs["subtract"].(func(int, int) int)
|
||||||
|
if sub(5, 3) != 2 {
|
||||||
|
t.Error("subtract(5,3) should be 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test basePath function.
|
||||||
|
bp := funcs["basePath"].(func() string)
|
||||||
|
if bp() != "/test" {
|
||||||
|
t.Errorf("basePath() = %q", bp())
|
||||||
|
}
|
||||||
|
}
|
||||||
75
internal/store/settings_test.go
Normal file
75
internal/store/settings_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsGetDefault(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
settings := NewSettingsStore(db)
|
||||||
|
|
||||||
|
s, err := settings.Get()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get default settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration should insert a default row.
|
||||||
|
if s.SiteName == "" {
|
||||||
|
t.Error("expected non-empty default site name")
|
||||||
|
}
|
||||||
|
if s.SignupMode == "" {
|
||||||
|
t.Error("expected non-empty default signup mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsUpdate(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
settings := NewSettingsStore(db)
|
||||||
|
|
||||||
|
err := settings.Update("Nye Favoritter", "En kul side", "requests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update settings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := settings.Get()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get after update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.SiteName != "Nye Favoritter" {
|
||||||
|
t.Errorf("site_name = %q, want %q", s.SiteName, "Nye Favoritter")
|
||||||
|
}
|
||||||
|
if s.SiteDescription != "En kul side" {
|
||||||
|
t.Errorf("site_description = %q, want %q", s.SiteDescription, "En kul side")
|
||||||
|
}
|
||||||
|
if s.SignupMode != "requests" {
|
||||||
|
t.Errorf("signup_mode = %q, want %q", s.SignupMode, "requests")
|
||||||
|
}
|
||||||
|
if s.UpdatedAt.IsZero() {
|
||||||
|
t.Error("expected non-zero updated_at after update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsUpdatePreservesOtherFields(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
settings := NewSettingsStore(db)
|
||||||
|
|
||||||
|
// Set initial values.
|
||||||
|
settings.Update("Site A", "Desc A", "open")
|
||||||
|
|
||||||
|
// Update with different values.
|
||||||
|
settings.Update("Site B", "Desc B", "closed")
|
||||||
|
|
||||||
|
s, _ := settings.Get()
|
||||||
|
if s.SiteName != "Site B" {
|
||||||
|
t.Errorf("site_name = %q, want %q", s.SiteName, "Site B")
|
||||||
|
}
|
||||||
|
if s.SiteDescription != "Desc B" {
|
||||||
|
t.Errorf("site_description = %q, want %q", s.SiteDescription, "Desc B")
|
||||||
|
}
|
||||||
|
if s.SignupMode != "closed" {
|
||||||
|
t.Errorf("signup_mode = %q, want %q", s.SignupMode, "closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue