CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s
Grunden er den samme som for postkassers kodeord og OAuth-tokens: en sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til. **Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst. **Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden: `notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk, og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads. **Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne; fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og delordssøgningen falder bort. bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest: titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren optælling laver, og den er let at lave og svær at få øje på. **En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — og det er præcis dét arkiv, der er værd at forsegle. Idempotent, så den koster et gennemløb af id'er hver anden gang, og `updated_at` røres ikke: at lukke en konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste den rækkefølge væk, importen brugte en dag på at hente hjem. En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans, der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der siger fra. Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden: opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke projekter og opgaver en note peger på, men ikke hvad der står. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
396 lines
13 KiB
Go
396 lines
13 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)
|
|
}
|
|
}
|
|
|
|
// Det, en note siger, må ikke kunne læses i filen, som en sikkerhedskopi kopierer.
|
|
//
|
|
// Grunden er den samme som for postkassers kodeord: en sikkerhedskopi kan hentes
|
|
// gennem fladen. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi,
|
|
// fordi der ligger sådan nogle i den installation, det her blev bygget til.
|
|
func TestANoteIsNotStoredInTheClear(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
const secretLine = "gamerule keepInventory true"
|
|
n := &Note{CreatedBy: userID, Body: "Minecraft Bedrock\n\n" + secretLine}
|
|
if err := db.SaveNote(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var title, body string
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT title, body FROM notes WHERE id = ?`, n.ID).Scan(&title, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(body, secretLine) {
|
|
t.Errorf("kroppen står i klartekst i rækken: %s", body)
|
|
}
|
|
// Og titlen med. Titlen *er* første linje, så en forseglet krop ved siden af en
|
|
// læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.
|
|
if strings.Contains(title, "Minecraft") {
|
|
t.Errorf("titlen står i klartekst i rækken: %s", title)
|
|
}
|
|
|
|
// Kalderens egen note er urørt: den har den i hånden og læser videre i den.
|
|
if n.Body != "Minecraft Bedrock\n\n"+secretLine {
|
|
t.Errorf("gemningen ændrede kalderens note: %q", n.Body)
|
|
}
|
|
|
|
// Læst tilbage er den sig selv igen.
|
|
back, err := db.Note(ctx, n.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back.Body != n.Body || back.Title != "Minecraft Bedrock" {
|
|
t.Errorf("noten kom anderledes tilbage: %+v", back)
|
|
}
|
|
|
|
// Og søgningen virker stadig — den åbner teksten nu, hvor den før spurgte et
|
|
// indeks, der var en klartekstkopi af den.
|
|
found, err := db.SearchNotes(ctx, "keep inventory", 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(found) != 1 {
|
|
t.Fatalf("søgningen fandt %d noter i en forseglet samling", len(found))
|
|
}
|
|
}
|
|
|
|
// Noter skrevet før nøglen fandtes skal lukkes, ikke efterlades.
|
|
//
|
|
// Uden en bagudfyldning ville kroppene blive forseglet efterhånden som de bliver
|
|
// *skrevet* — og et arkiv på tolv hundrede importerede noter, som ingen redigerer
|
|
// igen, ville aldrig blive det. Det er præcis dét arkiv, der er værd at forsegle.
|
|
func TestOldNotesAreSealedOnStartup(t *testing.T) {
|
|
db, userID := sealedStore(t)
|
|
ctx := context.Background()
|
|
|
|
// Skrevet uden om Go-laget, som en note fra før 0027 ligger.
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO notes (id, title, body, created_by, created_at, updated_at)
|
|
VALUES ('n-gammel', 'Immich DB', 'Immich DB\nkodeord: hemmeligt', ?, 100, 200)`,
|
|
userID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sealed, err := db.SealNotes(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sealed != 1 {
|
|
t.Fatalf("%d noter blev lukket, want 1", sealed)
|
|
}
|
|
|
|
var body string
|
|
var updated int64
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT body, updated_at FROM notes WHERE id = 'n-gammel'`).Scan(&body, &updated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(body, "hemmeligt") {
|
|
t.Errorf("den gamle note ligger stadig åben: %s", body)
|
|
}
|
|
// `updated_at` er ikke rørt. Det betyder "hvornår skrev dette program noten
|
|
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
|
|
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville kaste den
|
|
// rækkefølge væk, importen brugte en dag på at hente hjem.
|
|
if updated != 200 {
|
|
t.Errorf("bagudfyldningen flyttede updated_at til %d", updated)
|
|
}
|
|
|
|
// Og den kan læses.
|
|
back, err := db.Note(ctx, "n-gammel")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back == nil || !strings.Contains(back.Body, "hemmeligt") {
|
|
t.Fatalf("den gamle note kunne ikke læses tilbage: %+v", back)
|
|
}
|
|
|
|
// Kørt igen gør den ingenting: en note, der allerede er lukket, kendes på sit
|
|
// præfiks.
|
|
again, err := db.SealNotes(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again != 0 {
|
|
t.Errorf("anden kørsel lukkede %d noter om igen", again)
|
|
}
|
|
}
|