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
154 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|