Release / Build and publish image (push) Failing after 10m0s
Der er hverken en e-mail eller en adgangskode i et passkey-login. Loginet er discoverable — enheden ved selv, hvilken konto nøglen hører til, og der bliver ikke tastet noget. Alligevel fik en afvisning fladens generiske 401-tekst, og den nævner begge dele. Det sendte en person ud at lede efter en tastefejl i sin konto, der aldrig havde været der. Det, der faktisk var sket, var at instansen var flyttet fra http til https: en passkey hører til præcis den adresse, den blev lavet på — `RPOrigins` bygges af `VERDANDE_BASE_URL` med skema og alt — så et login på det ene skema mod en server, der venter det andet, kan ikke verificeres. Beskeden siger nu det, og siger også hvor man skal kigge. Serveren har hele tiden svaret "that key was not accepted"; det var oversættelsen i fladen, der gjorde den til noget andet. Aflysning bliver behandlet som før — den er ikke en fejl, og en fejl for den ville læse som om nøglen blev afvist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
8.9 KiB
Go
280 lines
8.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kristianwind/verdande/internal/secret"
|
|
)
|
|
|
|
// The whole point of the exercise: what is written must not be readable in the
|
|
// file that a backup copies.
|
|
func TestATokenIsNotStoredInTheClear(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
const token = "1//0-a-real-looking-refresh-token"
|
|
if err := db.SetUserSettings(ctx, userID, "gmail", map[string]any{
|
|
"refresh_token": token,
|
|
"label": "Verdande",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var raw string
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(raw, token) {
|
|
t.Fatalf("the token is in the row as written: %s", raw)
|
|
}
|
|
// The rest stays readable, which is the reason for naming the secret fields
|
|
// rather than sealing the whole blob.
|
|
if !strings.Contains(raw, "Verdande") {
|
|
t.Errorf("the label was sealed too: %s", raw)
|
|
}
|
|
|
|
back, err := db.UserSettings(ctx, userID, "gmail")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back["refresh_token"] != token {
|
|
t.Errorf("got %q back", back["refresh_token"])
|
|
}
|
|
}
|
|
|
|
// The migration that matters. A row written before there was a key must still be
|
|
// readable, and must put itself away sealed the next time it is written — nobody
|
|
// should have to reconnect a mailbox because the server learned to encrypt.
|
|
func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
const token = "1//0-written-when-there-was-no-key"
|
|
plain, _ := json.Marshal(map[string]any{"refresh_token": token})
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
|
VALUES (?, 'gmail', ?, 0)`, userID, string(plain)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
values, err := db.UserSettings(ctx, userID, "gmail")
|
|
if err != nil {
|
|
t.Fatalf("a row from before the key could not be read: %v", err)
|
|
}
|
|
if values["refresh_token"] != token {
|
|
t.Fatalf("got %q, want the plain token back", values["refresh_token"])
|
|
}
|
|
|
|
// Written back — and now sealed, without anything having asked for it.
|
|
if err := db.SetUserSettings(ctx, userID, "gmail", values); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var raw string
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(raw, token) {
|
|
t.Error("the row is still in the clear after being written back")
|
|
}
|
|
}
|
|
|
|
// The sweep finds people by the presence of a mailbox, so it must keep working
|
|
// now that the credential in it is ciphertext.
|
|
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
if err := db.SaveMailbox(ctx, &Mailbox{
|
|
UserID: userID, Kind: "gmail", RefreshToken: "1//0-token",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
users, err := db.UsersWithMailboxes(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(users) != 1 {
|
|
t.Fatalf("the sweep found %d people with mailboxes, want 1", len(users))
|
|
}
|
|
}
|
|
|
|
func sealedStore(t *testing.T) (*DB, string) {
|
|
t.Helper()
|
|
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
box, err := secret.Open("", t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.UseSecrets(box)
|
|
|
|
user := &User{Email: "kristian@example.dk", Name: "Kristian"}
|
|
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return db, user.ID
|
|
}
|
|
|
|
// Three integrations, one person, and none of them may delete the others.
|
|
//
|
|
// `user_settings` had `user_id` as its whole primary key and an upsert that
|
|
// rewrote `scope` on conflict, so every person had exactly one settings row and
|
|
// the last write won the lot: saving an AI provider deleted the Gmail connection,
|
|
// and connecting a calendar deleted the AI provider. The write reported success
|
|
// each time, which is why it was reported as "the AI settings are not saved"
|
|
// rather than as "connecting the calendar deleted them".
|
|
//
|
|
// Written against the store rather than the handlers, because the shape was the
|
|
// bug: every caller was correct on its own.
|
|
func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
want := map[string]map[string]any{
|
|
"ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"},
|
|
"gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"},
|
|
"calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"},
|
|
}
|
|
// In this order on purpose: the AI settings are written first and read last,
|
|
// so a scope that steals the row is caught by the one written earliest.
|
|
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
|
if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil {
|
|
t.Fatalf("save %s: %v", scope, err)
|
|
}
|
|
}
|
|
|
|
for _, scope := range []string{"ai", "gmail", "calendar"} {
|
|
got, err := db.UserSettings(ctx, userID, scope)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", scope, err)
|
|
}
|
|
for key, value := range want[scope] {
|
|
if got[key] != value {
|
|
t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And a second write to one scope updates it rather than adding another row —
|
|
// the upsert still has to upsert.
|
|
if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var rows int
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rows != 3 {
|
|
t.Errorf("the person has %d settings rows, want 3", rows)
|
|
}
|
|
back, err := db.UserSettings(ctx, userID, "ai")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back["provider"] != "openai" {
|
|
t.Errorf("the second write did not land: %v", back["provider"])
|
|
}
|
|
}
|
|
|
|
// The upgrade path, which is the one nobody runs: an existing database with a row
|
|
// in the old shape, opened by a binary that carries 0025.
|
|
//
|
|
// Every other test here starts empty and runs all the migrations in order, which
|
|
// exercises the migration's SQL but not its *situation*. The situation is a table
|
|
// that already holds somebody's settings, and the question is whether they come
|
|
// through it and whether a second scope can be written afterwards.
|
|
func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.db")
|
|
|
|
db, err := Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
user := &User{Email: "kristian@example.dk", Name: "Kristian"}
|
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Wind the database back to how v0.33.3 left it: user_id as the whole primary
|
|
// key, and 0025 not yet applied.
|
|
for _, stmt := range []string{
|
|
`PRAGMA foreign_keys = OFF`,
|
|
`CREATE TABLE us_old (
|
|
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
|
scope TEXT NOT NULL DEFAULT 'general',
|
|
values_json TEXT NOT NULL DEFAULT '{}',
|
|
updated_at INTEGER NOT NULL
|
|
)`,
|
|
`INSERT INTO us_old (user_id, scope, values_json, updated_at)
|
|
SELECT user_id, scope, values_json, updated_at FROM user_settings`,
|
|
`DROP TABLE user_settings`,
|
|
`ALTER TABLE us_old RENAME TO user_settings`,
|
|
`DELETE FROM schema_migrations WHERE name = '0025_user_settings_per_scope.sql'`,
|
|
`PRAGMA foreign_keys = ON`,
|
|
} {
|
|
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
|
t.Fatalf("rewind (%s): %v", stmt, err)
|
|
}
|
|
}
|
|
|
|
// A Gmail connection, written the way the old upsert wrote one.
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
|
VALUES (?, 'gmail', '{"label":"Verdande"}', 0)`, user.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// And now the new binary opens it.
|
|
db, err = Open(path)
|
|
if err != nil {
|
|
t.Fatalf("the upgrade refused to open the database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
gmail, err := db.UserSettings(ctx, user.ID, "gmail")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gmail["label"] != "Verdande" {
|
|
t.Errorf("the Gmail settings did not survive the migration: %v", gmail)
|
|
}
|
|
|
|
// The thing that was reported: set up the AI provider, and have it still be
|
|
// there afterwards.
|
|
if err := db.SetUserSettings(ctx, user.ID, "ai", map[string]any{
|
|
"provider": "anthropic", "model": "claude-sonnet-5",
|
|
}); err != nil {
|
|
t.Fatalf("saving the AI provider after the upgrade: %v", err)
|
|
}
|
|
ai, err := db.UserSettings(ctx, user.ID, "ai")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ai["provider"] != "anthropic" {
|
|
t.Errorf("the AI provider is %v after the upgrade, want anthropic", ai["provider"])
|
|
}
|
|
again, err := db.UserSettings(ctx, user.ID, "gmail")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again["label"] != "Verdande" {
|
|
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
|
|
}
|
|
}
|