Files
mathiasandClaude Sonnet 4.6 38f222c931
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 12s
chore: rename Go module path gitea.d-ma.be → git.d-ma.be
Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and
all .go import paths. Build and tests pass unchanged.

Closes #20

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
2026-07-02 14:37:33 +02:00

154 lines
5.2 KiB
Go

package web_test
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/web"
)
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
// account tests can assert the OAuth token cleanup without a real secret file.
type fakeSecrets struct {
mu sync.Mutex
deleted []string
}
func (f *fakeSecrets) Delete(ref string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deleted = append(f.deleted, ref)
return nil
}
func (f *fakeSecrets) deletedRefs() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.deleted...)
}
// newAccountApp builds the App as the registered stub user with a recording fake
// SecretStore, so disconnect/delete can be exercised end-to-end.
func newAccountApp(t *testing.T) (*web.App, *fakeSecrets) {
t.Helper()
s := newStore(t)
fs := &fakeSecrets{}
return &web.App{
Store: s,
Identity: s,
Auth: web.StubAuth{U: web.User{Subject: stubSubject, Email: "ada@example.com"}},
Secrets: fs,
}, fs
}
func seedConnection(t *testing.T, app *web.App, provider, account, ref string) {
t.Helper()
require.NoError(t, app.Store.(*store.Store).UpsertConnection(context.Background(), userID, store.Connection{
Provider: provider,
ProviderAccount: account,
TokenRef: ref,
Status: "active",
}))
}
func TestAccountPageShowsConnectionAndName(t *testing.T) {
ctx := context.Background()
app, _ := newAccountApp(t)
p := rawPool(t)
resetDB(t, p)
_, err := p.Exec(ctx, `UPDATE users SET display_name = $1 WHERE id = $2`, "Ada", userID)
require.NoError(t, err)
seedConnection(t, app, "youtube", "ada@channel", web.YouTubeTokenRef(userID))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Ada", "display name shown")
require.Contains(t, html, "ada@example.com", "signed-in email shown")
require.Contains(t, html, "ada@channel", "connected account shown")
require.Contains(t, html, "YouTube")
require.Contains(t, html, "/account/disconnect/youtube", "a Disconnect control is present")
require.NotContains(t, html, "/oauth/youtube/connect", "no Connect link while already connected")
// Confirm-before-destroy: the delete is behind a disclosure, not a bare button.
require.Contains(t, html, "/account/delete")
require.Contains(t, html, "<details", "delete is gated behind a confirm step")
require.Contains(t, html, "cannot be undone")
}
func TestAccountPageShowsConnectLinkWhenNotConnected(t *testing.T) {
app, _ := newAccountApp(t)
resetDB(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "/oauth/youtube/connect", "Connect link offered when not connected")
require.Contains(t, html, "Connect YouTube")
}
func TestDisconnectRemovesTokenAndConnectionKeepsAccount(t *testing.T) {
ctx := context.Background()
app, fs := newAccountApp(t)
resetDB(t, rawPool(t))
ref := web.YouTubeTokenRef(userID)
seedConnection(t, app, "youtube", "ada@channel", ref)
req := httptest.NewRequest(http.MethodPost, "/account/disconnect/youtube", nil)
rec := do(t, app, req)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/account", rec.Header().Get("Location"))
// Token purged from the SecretStore.
require.Contains(t, fs.deletedRefs(), ref, "the per-user YouTube token must be deleted")
// Connection row gone...
conns, err := app.Store.(*store.Store).ConnectionsForUser(ctx, userID)
require.NoError(t, err)
require.Empty(t, conns, "the connection row must be removed")
// ...but the account itself survives (disconnect is not delete).
name, err := app.Store.(*store.Store).DisplayName(ctx, userID)
require.NoError(t, err)
_ = name
var users int
require.NoError(t, rawPool(t).QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, userID).Scan(&users))
require.Equal(t, 1, users, "disconnect must not delete the account")
}
func TestDeleteAccountWipesDataAndSecretsAndLogsOut(t *testing.T) {
ctx := context.Background()
app, fs := newAccountApp(t)
p := rawPool(t)
resetDB(t, p)
ref := web.YouTubeTokenRef(userID)
seedConnection(t, app, "youtube", "ada@channel", ref)
require.NoError(t, deliver(ctx, app, videoX, "a summary")) // some user data to wipe
rec := do(t, app, httptest.NewRequest(http.MethodPost, "/account/delete", nil))
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/auth/logout", rec.Header().Get("Location"), "delete logs the user out")
// The user's secrets were removed (the per-user YouTube token).
require.Contains(t, fs.deletedRefs(), ref, "delete must purge the user's OAuth tokens")
// The account and its data are gone.
for _, q := range []string{
`SELECT count(*) FROM users WHERE id = $1`,
`SELECT count(*) FROM summaries WHERE user_id = $1`,
`SELECT count(*) FROM video_connections WHERE user_id = $1`,
`SELECT count(*) FROM user_identities WHERE user_id = $1`,
} {
var n int
require.NoError(t, p.QueryRow(ctx, q, userID).Scan(&n))
require.Equal(t, 0, n, "delete must remove all rows: %s", q)
}
}