Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions internal/store/sql_safety_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package store

import (
"reflect"
"testing"

"github.com/RandomCodeSpace/kb/internal/board"
)

func TestTasksByLinkBindsInjectionShapedScopeAndLink(t *testing.T) {
s := newStore(t)
scope := "alice' OR 1=1 --"
link := "link::gitlab#1')OR(1=1)--"
own, err := s.AddTask(scope, board.Task{Title: "own", Tags: []string{link}})
if err != nil {
t.Fatalf("AddTask(injection-shaped scope): %v", err)
}
if _, err := s.AddTask(scope, board.Task{Title: "wrong link", Tags: []string{"link::gitlab#other"}}); err != nil {
t.Fatalf("AddTask(wrong link): %v", err)
}
if _, err := s.AddTask("victim", board.Task{Title: "foreign", Tags: []string{link}}); err != nil {
t.Fatalf("AddTask(foreign scope): %v", err)
}

got, err := s.TasksByLink(scope, link)
if err != nil {
t.Fatalf("TasksByLink: %v", err)
}
want := []SimilarHit{{ID: own.ID, Title: own.Title, Status: string(own.Status), Via: "card", Link: link}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("TasksByLink = %+v, want only scoped exact match %+v", got, want)
}
}

func TestImportedAsBindsInjectionShapedScopeAndExternalKeys(t *testing.T) {
s := newStore(t)
scope := "alice' OR 1=1 --"
keys := []string{"shared-key", "key') OR 1=1 --", "foreign-only", "missing"}
own := []ImportLink{
{ExternalKey: keys[0], Link: "link::own-shared", Title: "own shared"},
{ExternalKey: keys[1], Link: "link::own-shaped", Title: "own shaped"},
}
foreign := []ImportLink{
{ExternalKey: keys[0], Link: "link::foreign-shared", Title: "foreign shared"},
{ExternalKey: keys[1], Link: "link::foreign-shaped", Title: "foreign shaped"},
{ExternalKey: keys[2], Link: "link::foreign-only", Title: "foreign only"},
}
if err := s.RecordImportLinks(scope, own); err != nil {
t.Fatalf("RecordImportLinks(injection-shaped scope): %v", err)
}
if err := s.RecordImportLinks("victim", foreign); err != nil {
t.Fatalf("RecordImportLinks(foreign scope): %v", err)
}

got, err := s.ImportedAs(scope, keys)
if err != nil {
t.Fatalf("ImportedAs: %v", err)
}
if _, ok := got[keys[2]]; ok {
t.Fatalf("ImportedAs returned foreign-only key %q: %+v", keys[2], got)
}
want := map[string]ImportLink{keys[0]: own[0], keys[1]: own[1]}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ImportedAs = %+v, want only scoped requested keys %+v", got, want)
}
}

func TestReplaceBoardBindsInjectionShapedScopeWhenLoadingExistingTasks(t *testing.T) {
s := newStore(t)
scope := "alice' OR 1=1 --"
scopedSeed := board.Board{Title: "Scoped", Tasks: []board.Task{
{Title: "dummy", Status: board.StatusTodo, Prio: 3},
{Title: "same title", Status: board.StatusTodo, Prio: 3},
}}
foreignSeed := board.Board{Title: "Foreign", Tasks: []board.Task{{Title: "same title", Status: board.StatusTodo, Prio: 3}}}
if err := s.ReplaceBoard(scope, scopedSeed); err != nil {
t.Fatalf("ReplaceBoard(injection-shaped scope): %v", err)
}
if err := s.ReplaceBoard("victim", foreignSeed); err != nil {
t.Fatalf("ReplaceBoard(foreign scope): %v", err)
}
before, err := s.Board(scope)
if err != nil {
t.Fatalf("Board(injection-shaped scope): %v", err)
}
foreignBefore, err := s.Board("victim")
if err != nil {
t.Fatalf("Board(foreign scope): %v", err)
}
if len(before.Tasks) != 2 || before.Tasks[0].Title != "dummy" || before.Tasks[0].Position != 0 || before.Tasks[1].Title != "same title" || before.Tasks[1].Position != 1 {
t.Fatalf("scoped ordering precondition failed: %+v", before.Tasks)
}
if len(foreignBefore.Tasks) != 1 || foreignBefore.Tasks[0].Title != "same title" || foreignBefore.Tasks[0].Position != 0 {
t.Fatalf("foreign ordering precondition failed: %+v", foreignBefore.Tasks)
}

updated := board.Board{Title: "Scoped", Tasks: []board.Task{
{Title: "dummy", Status: board.StatusTodo, Prio: 3},
{Title: "same title", Desc: "updated", Status: board.StatusTodo, Prio: 3},
}}
if err := s.ReplaceBoard(scope, updated); err != nil {
t.Fatalf("ReplaceBoard update: %v", err)
}
after, err := s.Board(scope)
if err != nil {
t.Fatalf("Board after update: %v", err)
}
foreignAfter, err := s.Board("victim")
if err != nil {
t.Fatalf("Board(foreign scope) after update: %v", err)
}
if len(before.Tasks) != 2 || len(after.Tasks) != 2 || after.Tasks[1].ID != before.Tasks[1].ID {
t.Fatalf("scoped identity changed: before=%+v after=%+v", before.Tasks, after.Tasks)
}
if !reflect.DeepEqual(foreignAfter, foreignBefore) {
t.Fatalf("foreign board changed: before=%+v after=%+v", foreignBefore, foreignAfter)
}
}
34 changes: 25 additions & 9 deletions src/components/IdentityGate.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { StrictMode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IdentityGate } from './IdentityGate';

Expand Down Expand Up @@ -42,14 +43,29 @@ describe('IdentityGate DOM', () => {
expect((await screen.findByRole('alert')).textContent).toContain('Sign-in failed');
});

it('does not publish Azure availability after unmount', async () => {
let resolve!: (value: boolean) => void;
auth.azureAvailable.mockReturnValue(new Promise<boolean>((r) => { resolve = r; }));
const { container, unmount } = render(<IdentityGate onIdentity={vi.fn()} />);
unmount();
resolve(true);
await Promise.resolve();
expect(container).toBeEmptyDOMElement();
it('ignores Azure availability from a cleaned-up StrictMode effect', async () => {
const availability = Array.from({ length: 2 }, () => {
let resolve!: (value: boolean) => void;
const promise = new Promise<boolean>((done) => { resolve = done; });
return { promise, resolve };
});
auth.azureAvailable
.mockReturnValueOnce(availability[0].promise)
.mockReturnValueOnce(availability[1].promise);

render(
<StrictMode>
<IdentityGate onIdentity={vi.fn()} />
</StrictMode>,
);
await waitFor(() => expect(auth.azureAvailable).toHaveBeenCalledTimes(2));
const azure = screen.getByRole('button', { name: 'Sign in with Microsoft' });

await act(async () => availability[0].resolve(true));
expect(azure).toBeDisabled();

await act(async () => availability[1].resolve(true));
await waitFor(() => expect(azure).toBeEnabled());
});

it('handles availability rejection, provider Error, blank submit, invalid characters, and token omission', async () => {
Expand Down
71 changes: 71 additions & 0 deletions src/lib/outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ const importRequest = {
}],
};

const unorderedExternalKeys = ['a', '😀', '2', 'é', 'A', '10'];
const codeUnitOrderedExternalKeys = ['é', '😀', '10', '2', 'A', 'a'];

function importRequestFor(externalKey: string) {
return {
source: 'ordering',
items: [{
external_key: externalKey,
link: `link-${externalKey}`,
url: `https://example.test/${encodeURIComponent(externalKey)}`,
title: `title-${externalKey}`,
}],
};
}

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
Expand All @@ -83,6 +98,62 @@ beforeEach(() => {
});

describe('MetadataOutbox', () => {
it('lists mixed external keys in storage-key code-unit order regardless of insertion order', async () => {
const outbox = new MetadataOutbox('alice', {
storage, locks: locks as unknown as LockManager, generation: generations(),
});
for (const externalKey of unorderedExternalKeys) {
await outbox.enqueueImportLinks(importRequestFor(externalKey));
}

expect(outbox.records().map((record) =>
record.kind === 'import' ? record.item.external_key : record.clientTaskId,
)).toEqual(codeUnitOrderedExternalKeys);
});

it('drains mixed external keys in storage-key code-unit order regardless of insertion order', async () => {
const sent: string[] = [];
const outbox = new MetadataOutbox('alice', {
storage,
locks: locks as unknown as LockManager,
generation: generations(),
sendImport: (_identity, request) => {
sent.push(request.items[0]!.external_key);
return Promise.resolve();
},
});
for (const externalKey of unorderedExternalKeys) {
await outbox.enqueueImportLinks(importRequestFor(externalKey));
}

await outbox.drain(identity);

expect(sent).toEqual(codeUnitOrderedExternalKeys);
});

it('reconciles mixed external keys in storage-key code-unit order regardless of insertion order', async () => {
const outbox = new MetadataOutbox('alice', {
storage, locks: locks as unknown as LockManager, generation: generations(),
});
for (const externalKey of unorderedExternalKeys) {
await outbox.enqueueImportLinks(importRequestFor(externalKey));
}
for (const [key, raw] of storage.values) {
storage.values.set(key, JSON.stringify({ ...JSON.parse(raw), state: 'sending' }));
}
const reconciled: string[] = [];
const setItem = vi.spyOn(storage, 'setItem').mockImplementation((key, value) => {
storage.values.set(key, String(value));
const record = JSON.parse(String(value)) as { item?: { external_key?: string } };
if (record.item?.external_key) reconciled.push(record.item.external_key);
});

await outbox.reconcile(cancelled, new Map());

expect(reconciled).toEqual(codeUnitOrderedExternalKeys);
setItem.mockRestore();
});

it.each(['acknowledgement', 'startup'])(
'blocks %s drain when durable reconciliation fails',
async () => {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/outbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ function logicalKey(record: OutboxRecord): string {
: importLogicalKey(record.item.external_key);
}

function compareCodeUnits(left: string, right: string): number {
return Number(left > right) - Number(left < right);
}

/** Durable, credential-free metadata journal for one immutable user namespace. */
export class MetadataOutbox {
private readonly storage: Storage;
Expand Down Expand Up @@ -375,7 +379,7 @@ export class MetadataOutbox {
const key = this.storage.key(i);
if (key && namespaceStorageSuffix(PREFIX, this.ns, key) !== null) keys.push(key);
}
return keys.sort((left, right) => left.localeCompare(right));
return keys.sort(compareCodeUnits);
}

private async locked<T>(fn: () => T | Promise<T>): Promise<T | undefined> {
Expand Down